add voice service call service and more

This commit is contained in:
2025-11-25 16:49:18 +03:30
parent 60d20a2734
commit 9ba81d944f
49 changed files with 4428 additions and 19 deletions

View File

@@ -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<ActionResult<SendCodeResponse>> 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<ActionResult<VerifyCodeResponse>> 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<ActionResult<bool>> CanResend(
[FromQuery] string mobile,
CancellationToken cancellationToken)
{
var canResend = await authService.CanResendCodeAsync(mobile, cancellationToken);
return Ok(new { canResend });
}
}

View File

@@ -35,4 +35,31 @@ public class DevicesController : ControllerBase
var id = await deviceService.AddDeviceAsync(dto, cancellationToken); var id = await deviceService.AddDeviceAsync(dto, cancellationToken);
return Ok(id); return Ok(id);
} }
[HttpGet("user/{userId}")]
public async Task<ActionResult<IReadOnlyList<DeviceDto>>> GetUserDevices(int userId, CancellationToken cancellationToken)
{
var result = await deviceService.GetUserDevicesAsync(userId, cancellationToken);
return Ok(result);
}
[HttpGet("filtered")]
public async Task<ActionResult<PagedResult<DeviceDto>>> 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);
}
} }

View File

@@ -0,0 +1,138 @@
using GreenHome.Sms.Ippanel;
using Microsoft.AspNetCore.Mvc;
namespace GreenHome.Api.Controllers;
/// <summary>
/// Controller for testing SMS service
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class SmsTestController : ControllerBase
{
private readonly ISmsService smsService;
private readonly ILogger<SmsTestController> logger;
public SmsTestController(ISmsService smsService, ILogger<SmsTestController> logger)
{
this.smsService = smsService;
this.logger = logger;
}
/// <summary>
/// Test sending Webservice SMS
/// </summary>
/// <param name="request">SMS request details</param>
/// <returns>SMS response</returns>
[HttpPost("webservice")]
public async Task<IActionResult> 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
});
}
}
/// <summary>
/// Test sending Pattern SMS
/// </summary>
/// <param name="request">Pattern SMS request details</param>
/// <returns>SMS response</returns>
[HttpPost("pattern")]
public async Task<IActionResult> 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
});
}
}
/// <summary>
/// Quick test with default values (for easy testing)
/// </summary>
/// <param name="phoneNumber">Phone number to send SMS to</param>
/// <returns>SMS response</returns>
[HttpPost("quick-test/{phoneNumber}")]
public async Task<IActionResult> 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
});
}
}
}

View File

@@ -8,10 +8,12 @@ namespace GreenHome.Api.Controllers;
public class TelemetryController : ControllerBase public class TelemetryController : ControllerBase
{ {
private readonly ITelemetryService telemetryService; private readonly ITelemetryService telemetryService;
private readonly IAlertService alertService;
public TelemetryController(ITelemetryService telemetryService) public TelemetryController(ITelemetryService telemetryService, IAlertService alertService)
{ {
this.telemetryService = telemetryService; this.telemetryService = telemetryService;
this.alertService = alertService;
} }
[HttpGet] [HttpGet]
@@ -37,6 +39,26 @@ public class TelemetryController : ControllerBase
TimestampUtc = DateTime.UtcNow TimestampUtc = DateTime.UtcNow
}; };
var id = await telemetryService.AddAsync(dto, cancellationToken); 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); return Ok(id);
} }

View File

@@ -0,0 +1,270 @@
using GreenHome.VoiceCall.Avanak;
using Microsoft.AspNetCore.Mvc;
namespace GreenHome.Api.Controllers;
/// <summary>
/// Controller for testing Voice Call service (Avanak)
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class VoiceCallTestController : ControllerBase
{
private readonly IVoiceCallService voiceCallService;
private readonly ILogger<VoiceCallTestController> logger;
public VoiceCallTestController(IVoiceCallService voiceCallService, ILogger<VoiceCallTestController> logger)
{
this.voiceCallService = voiceCallService;
this.logger = logger;
}
/// <summary>
/// Test getting account status
/// </summary>
/// <returns>Account status response</returns>
[HttpGet("account-status")]
public async Task<IActionResult> 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
});
}
}
/// <summary>
/// Test generating TTS (Text-to-Speech)
/// </summary>
/// <param name="request">TTS generation request</param>
/// <returns>TTS generation response</returns>
[HttpPost("generate-tts")]
public async Task<IActionResult> 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
});
}
}
/// <summary>
/// Test QuickSend (sending voice call with uploaded audio file)
/// </summary>
/// <param name="request">QuickSend request</param>
/// <returns>QuickSend response</returns>
[HttpPost("quick-send")]
public async Task<IActionResult> 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
});
}
}
/// <summary>
/// Test QuickSendWithTTS (sending voice call with TTS)
/// </summary>
/// <param name="request">QuickSendWithTTS request</param>
/// <returns>QuickSendWithTTS response</returns>
[HttpPost("quick-send-with-tts")]
public async Task<IActionResult> 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
});
}
}
/// <summary>
/// Test getting QuickSend status
/// </summary>
/// <param name="request">GetQuickSend request</param>
/// <returns>QuickSend status response</returns>
[HttpPost("quick-send-status")]
public async Task<IActionResult> 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
});
}
}
/// <summary>
/// Quick test with default values (for easy testing)
/// </summary>
/// <param name="phoneNumber">Phone number to send voice call to</param>
/// <param name="text">Text to convert to speech (optional)</param>
/// <returns>QuickSendWithTTS response</returns>
[HttpPost("quick-test/{phoneNumber}")]
public async Task<IActionResult> 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
});
}
}
}

View File

@@ -19,6 +19,8 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\GreenHome.Application\GreenHome.Application.csproj" /> <ProjectReference Include="..\GreenHome.Application\GreenHome.Application.csproj" />
<ProjectReference Include="..\GreenHome.Infrastructure\GreenHome.Infrastructure.csproj" /> <ProjectReference Include="..\GreenHome.Infrastructure\GreenHome.Infrastructure.csproj" />
<ProjectReference Include="..\GreenHome.Sms.Ippanel\GreenHome.Sms.Ippanel.csproj" />
<ProjectReference Include="..\GreenHome.VoiceCall.Avanak\GreenHome.VoiceCall.Avanak.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,6 +1,8 @@
using FluentValidation; using FluentValidation;
using GreenHome.Application; using GreenHome.Application;
using GreenHome.Infrastructure; using GreenHome.Infrastructure;
using GreenHome.Sms.Ippanel;
using GreenHome.VoiceCall.Avanak;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -42,9 +44,33 @@ builder.Services.AddDbContext<GreenHome.Infrastructure.GreenHomeDbContext>(optio
builder.Services.AddScoped<GreenHome.Application.IDeviceService, GreenHome.Infrastructure.DeviceService>(); builder.Services.AddScoped<GreenHome.Application.IDeviceService, GreenHome.Infrastructure.DeviceService>();
builder.Services.AddScoped<GreenHome.Application.ITelemetryService, GreenHome.Infrastructure.TelemetryService>(); builder.Services.AddScoped<GreenHome.Application.ITelemetryService, GreenHome.Infrastructure.TelemetryService>();
builder.Services.AddScoped<GreenHome.Application.IDeviceSettingsService, GreenHome.Infrastructure.DeviceSettingsService>(); builder.Services.AddScoped<GreenHome.Application.IDeviceSettingsService, GreenHome.Infrastructure.DeviceSettingsService>();
builder.Services.AddScoped<GreenHome.Application.IAuthService, GreenHome.Infrastructure.AuthService>();
builder.Services.AddScoped<GreenHome.Application.IAlertService, GreenHome.Infrastructure.AlertService>();
// SMS Service Configuration
builder.Services.AddIppanelSms(builder.Configuration);
// Voice Call Service Configuration
builder.Services.AddAvanakVoiceCall(builder.Configuration);
var app = builder.Build(); var app = builder.Build();
// Apply pending migrations automatically
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
try
{
var context = services.GetRequiredService<GreenHome.Infrastructure.GreenHomeDbContext>();
context.Database.Migrate();
}
catch (Exception ex)
{
var logger = services.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "An error occurred while migrating the database.");
}
}
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
//if (app.Environment.IsDevelopment()) //if (app.Environment.IsDevelopment())
{ {

View File

@@ -6,7 +6,12 @@
} }
}, },
"ConnectionStrings": { "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": "*" "AllowedHosts": "*"
} }

View File

@@ -7,8 +7,10 @@ public sealed class DeviceDto
{ {
public int Id { get; set; } public int Id { get; set; }
public string DeviceName { get; set; } = string.Empty; public string DeviceName { get; set; } = string.Empty;
public string Owner { get; set; } = string.Empty; public int UserId { get; set; }
public string Mobile { get; set; } = string.Empty; 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 Location { get; set; } = string.Empty;
public string NeshanLocation { 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 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 sealed class DeviceSettingsDto
{ {
public int Id { get; set; } public int Id { get; set; }

View File

@@ -0,0 +1,7 @@
namespace GreenHome.Application;
public interface IAlertService
{
Task CheckAndSendAlertsAsync(int deviceId, TelemetryDto telemetry, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,9 @@
namespace GreenHome.Application;
public interface IAuthService
{
Task<SendCodeResponse> SendVerificationCodeAsync(SendCodeRequest request, CancellationToken cancellationToken);
Task<VerifyCodeResponse> VerifyCodeAsync(VerifyCodeRequest request, CancellationToken cancellationToken);
Task<bool> CanResendCodeAsync(string mobile, CancellationToken cancellationToken);
}

View File

@@ -6,4 +6,6 @@ public interface IDeviceService
Task<int> AddDeviceAsync(DeviceDto dto, CancellationToken cancellationToken); Task<int> AddDeviceAsync(DeviceDto dto, CancellationToken cancellationToken);
Task<IReadOnlyList<DeviceDto>> ListAsync(CancellationToken cancellationToken); Task<IReadOnlyList<DeviceDto>> ListAsync(CancellationToken cancellationToken);
Task<DeviceDto> GetDeviceId(string deviceName, CancellationToken cancellationToken); Task<DeviceDto> GetDeviceId(string deviceName, CancellationToken cancellationToken);
Task<IReadOnlyList<DeviceDto>> GetUserDevicesAsync(int userId, CancellationToken cancellationToken);
Task<PagedResult<DeviceDto>> GetDevicesAsync(DeviceFilter filter, CancellationToken cancellationToken);
} }

View File

@@ -7,11 +7,20 @@ public sealed class MappingProfile : Profile
{ {
public MappingProfile() public MappingProfile()
{ {
CreateMap<Domain.Device, DeviceDto>().ReverseMap(); CreateMap<Domain.Device, DeviceDto>()
.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<Domain.TelemetryRecord, TelemetryDto>().ReverseMap(); CreateMap<Domain.TelemetryRecord, TelemetryDto>().ReverseMap();
CreateMap<Domain.DeviceSettings, DeviceSettingsDto>() CreateMap<Domain.DeviceSettings, DeviceSettingsDto>()
.ForMember(dest => dest.DeviceName, opt => opt.MapFrom(src => src.Device.DeviceName)) .ForMember(dest => dest.DeviceName, opt => opt.MapFrom(src => src.Device.DeviceName))
.ReverseMap() .ReverseMap()
.ForMember(dest => dest.Device, opt => opt.Ignore()); .ForMember(dest => dest.Device, opt => opt.Ignore());
CreateMap<Domain.User, UserDto>().ReverseMap();
} }
} }

View File

@@ -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;
}

View File

@@ -4,8 +4,9 @@ public sealed class Device
{ {
public int Id { get; set; } public int Id { get; set; }
public string DeviceName { get; set; } = string.Empty; // varchar(10) public string DeviceName { get; set; } = string.Empty; // varchar(10)
public string Owner { get; set; } = string.Empty; // نام صاحب public int UserId { get; set; }
public string Mobile { get; set; } = string.Empty; public User User { get; set; } = null!;
public string Location { get; set; } = string.Empty; // varchar(250) public string Location { get; set; } = string.Empty; // varchar(250)
public string NeshanLocation { get; set; } = string.Empty; // varchar(80) public string NeshanLocation { get; set; } = string.Empty; // varchar(80)
public ICollection<DeviceUser> DeviceUsers { get; set; } = new List<DeviceUser>();
} }

View File

@@ -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!;
}

View File

@@ -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<DeviceUser> DeviceUsers { get; set; } = new List<DeviceUser>();
}

View File

@@ -0,0 +1,9 @@
namespace GreenHome.Domain;
public enum UserRole
{
Normal = 0, // کاربر عادی
Admin = 1, // ادمین
Supervisor = 2 // ناظر
}

View File

@@ -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; }
}

View File

@@ -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<AlertService> 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<AlertService> 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<AlertInfo> CollectAlerts(TelemetryDto telemetry, DeviceSettingsDto settings, string deviceName)
{
var alerts = new List<AlertInfo>();
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<AlertInfo> 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<AlertInfo> 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<AlertInfo> 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<AlertInfo> 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<AlertInfo> 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<string, string> {
{ "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<string>();
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);
}
}

View File

@@ -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<AuthService> logger;
private const int CodeExpirationMinutes = 5;
private const int ResendCooldownSeconds = 120;
public AuthService(
GreenHomeDbContext dbContext,
ISmsService smsService,
ILogger<AuthService> logger)
{
this.dbContext = dbContext;
this.smsService = smsService;
this.logger = logger;
}
public async Task<SendCodeResponse> 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<string, string> { { "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<VerifyCodeResponse> 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<bool> 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;
}
}

View File

@@ -25,13 +25,102 @@ public sealed class DeviceService : IDeviceService
public async Task<IReadOnlyList<DeviceDto>> ListAsync(CancellationToken cancellationToken) public async Task<IReadOnlyList<DeviceDto>> 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<IReadOnlyList<DeviceDto>>(items); return mapper.Map<IReadOnlyList<DeviceDto>>(items);
} }
public async Task<DeviceDto> GetDeviceId(string deviceName,CancellationToken cancellationToken) public async Task<DeviceDto> 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<DeviceDto>(item); return mapper.Map<DeviceDto>(item);
} }
public async Task<IReadOnlyList<DeviceDto>> 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<IReadOnlyList<DeviceDto>>(items);
}
public async Task<PagedResult<DeviceDto>> 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<DeviceDto>
{
Items = Array.Empty<DeviceDto>(),
TotalCount = 0,
Page = filter.Page,
PageSize = filter.PageSize
};
}
// Build query based on role
IQueryable<Domain.Device> 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<DeviceDto>
{
Items = mapper.Map<IReadOnlyList<DeviceDto>>(items),
TotalCount = totalCount,
Page = filter.Page,
PageSize = filter.PageSize
};
}
} }

View File

@@ -22,6 +22,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\GreenHome.Application\GreenHome.Application.csproj" /> <ProjectReference Include="..\GreenHome.Application\GreenHome.Application.csproj" />
<ProjectReference Include="..\GreenHome.Domain\GreenHome.Domain.csproj" /> <ProjectReference Include="..\GreenHome.Domain\GreenHome.Domain.csproj" />
<ProjectReference Include="..\GreenHome.Sms.Ippanel\GreenHome.Sms.Ippanel.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -9,6 +9,10 @@ public sealed class GreenHomeDbContext : DbContext
public DbSet<Domain.Device> Devices => Set<Domain.Device>(); public DbSet<Domain.Device> Devices => Set<Domain.Device>();
public DbSet<Domain.TelemetryRecord> TelemetryRecords => Set<Domain.TelemetryRecord>(); public DbSet<Domain.TelemetryRecord> TelemetryRecords => Set<Domain.TelemetryRecord>();
public DbSet<Domain.DeviceSettings> DeviceSettings => Set<Domain.DeviceSettings>(); public DbSet<Domain.DeviceSettings> DeviceSettings => Set<Domain.DeviceSettings>();
public DbSet<Domain.User> Users => Set<Domain.User>();
public DbSet<Domain.VerificationCode> VerificationCodes => Set<Domain.VerificationCode>();
public DbSet<Domain.DeviceUser> DeviceUsers => Set<Domain.DeviceUser>();
public DbSet<Domain.AlertNotification> AlertNotifications => Set<Domain.AlertNotification>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -19,10 +23,13 @@ public sealed class GreenHomeDbContext : DbContext
b.ToTable("Devices"); b.ToTable("Devices");
b.HasKey(x => x.Id); b.HasKey(x => x.Id);
b.Property(x => x.DeviceName).IsRequired().HasMaxLength(10); b.Property(x => x.DeviceName).IsRequired().HasMaxLength(10);
b.Property(x => x.Owner).IsRequired(); b.Property(x => x.UserId).IsRequired();
b.Property(x => x.Mobile).IsRequired(false);
b.Property(x => x.Location).HasMaxLength(250); b.Property(x => x.Location).HasMaxLength(250);
b.Property(x => x.NeshanLocation).HasMaxLength(80); b.Property(x => x.NeshanLocation).HasMaxLength(80);
b.HasOne(x => x.User)
.WithMany()
.HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.Restrict);
}); });
modelBuilder.Entity<Domain.TelemetryRecord>(b => modelBuilder.Entity<Domain.TelemetryRecord>(b =>
@@ -56,5 +63,58 @@ public sealed class GreenHomeDbContext : DbContext
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasIndex(x => x.DeviceId).IsUnique(); b.HasIndex(x => x.DeviceId).IsUnique();
}); });
modelBuilder.Entity<Domain.User>(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<int>();
b.HasIndex(x => x.Mobile).IsUnique();
});
modelBuilder.Entity<Domain.DeviceUser>(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<Domain.VerificationCode>(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<Domain.AlertNotification>(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 });
});
} }
} }

View File

@@ -0,0 +1,263 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(250)
.HasColumnType("nvarchar(250)");
b.Property<string>("NeshanLocation")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Devices", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<decimal>("DangerMaxTemperature")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("DangerMinTemperature")
.HasColumnType("decimal(18,2)");
b.Property<int>("DeviceId")
.HasColumnType("int");
b.Property<int>("MaxGasPPM")
.HasColumnType("int");
b.Property<decimal>("MaxHumidityPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("MaxLux")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("MaxTemperature")
.HasColumnType("decimal(18,2)");
b.Property<int>("MinGasPPM")
.HasColumnType("int");
b.Property<decimal>("MinHumidityPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("MinLux")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("MinTemperature")
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("DeviceId")
.IsUnique();
b.ToTable("DeviceSettings", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.TelemetryRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DeviceId")
.HasColumnType("int");
b.Property<int>("GasPPM")
.HasColumnType("int");
b.Property<decimal>("HumidityPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("Lux")
.HasColumnType("decimal(18,2)");
b.Property<string>("PersianDate")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<int>("PersianMonth")
.HasColumnType("int");
b.Property<int>("PersianYear")
.HasColumnType("int");
b.Property<decimal>("SoilPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("TemperatureC")
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Family")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("datetime2");
b.Property<string>("Mobile")
.IsRequired()
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<bool>("IsUsed")
.HasColumnType("bit");
b.Property<string>("Mobile")
.IsRequired()
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<DateTime?>("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
}
}
}

View File

@@ -0,0 +1,124 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GreenHome.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class UpdateDeviceUserRelationship : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Mobile",
table: "Devices");
migrationBuilder.DropColumn(
name: "Owner",
table: "Devices");
migrationBuilder.AddColumn<int>(
name: "UserId",
table: "Devices",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Mobile = table.Column<string>(type: "nvarchar(11)", maxLength: 11, nullable: false),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Family = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
LastLoginAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "VerificationCodes",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Mobile = table.Column<string>(type: "nvarchar(11)", maxLength: 11, nullable: false),
Code = table.Column<string>(type: "nvarchar(4)", maxLength: 4, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IsUsed = table.Column<bool>(type: "bit", nullable: false),
UsedAt = table.Column<DateTime>(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);
}
/// <inheritdoc />
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<string>(
name: "Mobile",
table: "Devices",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Owner",
table: "Devices",
type: "nvarchar(max)",
nullable: false,
defaultValue: "");
}
}
}

View File

@@ -0,0 +1,310 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(250)
.HasColumnType("nvarchar(250)");
b.Property<string>("NeshanLocation")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Devices", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<decimal>("DangerMaxTemperature")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("DangerMinTemperature")
.HasColumnType("decimal(18,2)");
b.Property<int>("DeviceId")
.HasColumnType("int");
b.Property<int>("MaxGasPPM")
.HasColumnType("int");
b.Property<decimal>("MaxHumidityPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("MaxLux")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("MaxTemperature")
.HasColumnType("decimal(18,2)");
b.Property<int>("MinGasPPM")
.HasColumnType("int");
b.Property<decimal>("MinHumidityPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("MinLux")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("MinTemperature")
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("DeviceId")
.IsUnique();
b.ToTable("DeviceSettings", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.DeviceUser", b =>
{
b.Property<int>("DeviceId")
.HasColumnType("int");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("DeviceId", "UserId");
b.HasIndex("UserId");
b.ToTable("DeviceUsers", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.TelemetryRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DeviceId")
.HasColumnType("int");
b.Property<int>("GasPPM")
.HasColumnType("int");
b.Property<decimal>("HumidityPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("Lux")
.HasColumnType("decimal(18,2)");
b.Property<string>("PersianDate")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<int>("PersianMonth")
.HasColumnType("int");
b.Property<int>("PersianYear")
.HasColumnType("int");
b.Property<decimal>("SoilPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("TemperatureC")
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Family")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("datetime2");
b.Property<string>("Mobile")
.IsRequired()
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("Role")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Mobile")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.VerificationCode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<bool>("IsUsed")
.HasColumnType("bit");
b.Property<string>("Mobile")
.IsRequired()
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<DateTime?>("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
}
}
}

View File

@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GreenHome.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddUserRolesAndDeviceUsers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Role",
table: "Users",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "DeviceUsers",
columns: table => new
{
DeviceId = table.Column<int>(type: "int", nullable: false),
UserId = table.Column<int>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DeviceUsers");
migrationBuilder.DropColumn(
name: "Role",
table: "Users");
}
}
}

View File

@@ -0,0 +1,368 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AlertType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("DeviceId")
.HasColumnType("int");
b.Property<bool>("IsSent")
.HasColumnType("bit");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime>("SentAt")
.HasColumnType("datetime2");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(250)
.HasColumnType("nvarchar(250)");
b.Property<string>("NeshanLocation")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Devices", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<decimal>("DangerMaxTemperature")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("DangerMinTemperature")
.HasColumnType("decimal(18,2)");
b.Property<int>("DeviceId")
.HasColumnType("int");
b.Property<int>("MaxGasPPM")
.HasColumnType("int");
b.Property<decimal>("MaxHumidityPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("MaxLux")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("MaxTemperature")
.HasColumnType("decimal(18,2)");
b.Property<int>("MinGasPPM")
.HasColumnType("int");
b.Property<decimal>("MinHumidityPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("MinLux")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("MinTemperature")
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("DeviceId")
.IsUnique();
b.ToTable("DeviceSettings", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.DeviceUser", b =>
{
b.Property<int>("DeviceId")
.HasColumnType("int");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("DeviceId", "UserId");
b.HasIndex("UserId");
b.ToTable("DeviceUsers", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.TelemetryRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DeviceId")
.HasColumnType("int");
b.Property<int>("GasPPM")
.HasColumnType("int");
b.Property<decimal>("HumidityPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("Lux")
.HasColumnType("decimal(18,2)");
b.Property<string>("PersianDate")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<int>("PersianMonth")
.HasColumnType("int");
b.Property<int>("PersianYear")
.HasColumnType("int");
b.Property<decimal>("SoilPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("TemperatureC")
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Family")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("datetime2");
b.Property<string>("Mobile")
.IsRequired()
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("Role")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Mobile")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.VerificationCode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<bool>("IsUsed")
.HasColumnType("bit");
b.Property<string>("Mobile")
.IsRequired()
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<DateTime?>("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
}
}
}

View File

@@ -0,0 +1,62 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GreenHome.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAlertNotifications : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AlertNotifications",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
DeviceId = table.Column<int>(type: "int", nullable: false),
UserId = table.Column<int>(type: "int", nullable: false),
AlertType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Message = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
SentAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IsSent = table.Column<bool>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AlertNotifications");
}
}
}

View File

@@ -0,0 +1,376 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AlertType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("DeviceId")
.HasColumnType("int");
b.Property<string>("ErrorMessage")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<bool>("IsSent")
.HasColumnType("bit");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("MessageOutboxIds")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime>("SentAt")
.HasColumnType("datetime2");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(250)
.HasColumnType("nvarchar(250)");
b.Property<string>("NeshanLocation")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Devices", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<decimal>("DangerMaxTemperature")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("DangerMinTemperature")
.HasColumnType("decimal(18,2)");
b.Property<int>("DeviceId")
.HasColumnType("int");
b.Property<int>("MaxGasPPM")
.HasColumnType("int");
b.Property<decimal>("MaxHumidityPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("MaxLux")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("MaxTemperature")
.HasColumnType("decimal(18,2)");
b.Property<int>("MinGasPPM")
.HasColumnType("int");
b.Property<decimal>("MinHumidityPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("MinLux")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("MinTemperature")
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("DeviceId")
.IsUnique();
b.ToTable("DeviceSettings", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.DeviceUser", b =>
{
b.Property<int>("DeviceId")
.HasColumnType("int");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("DeviceId", "UserId");
b.HasIndex("UserId");
b.ToTable("DeviceUsers", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.TelemetryRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DeviceId")
.HasColumnType("int");
b.Property<int>("GasPPM")
.HasColumnType("int");
b.Property<decimal>("HumidityPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("Lux")
.HasColumnType("decimal(18,2)");
b.Property<string>("PersianDate")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<int>("PersianMonth")
.HasColumnType("int");
b.Property<int>("PersianYear")
.HasColumnType("int");
b.Property<decimal>("SoilPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("TemperatureC")
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Family")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("datetime2");
b.Property<string>("Mobile")
.IsRequired()
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("Role")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Mobile")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.VerificationCode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<bool>("IsUsed")
.HasColumnType("bit");
b.Property<string>("Mobile")
.IsRequired()
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<DateTime?>("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
}
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GreenHome.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class FixAlertNotifications : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ErrorMessage",
table: "AlertNotifications",
type: "nvarchar(1000)",
maxLength: 1000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "MessageOutboxIds",
table: "AlertNotifications",
type: "nvarchar(500)",
maxLength: 500,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ErrorMessage",
table: "AlertNotifications");
migrationBuilder.DropColumn(
name: "MessageOutboxIds",
table: "AlertNotifications");
}
}
}

View File

@@ -22,6 +22,53 @@ namespace GreenHome.Infrastructure.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("GreenHome.Domain.AlertNotification", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AlertType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("DeviceId")
.HasColumnType("int");
b.Property<string>("ErrorMessage")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<bool>("IsSent")
.HasColumnType("bit");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("MessageOutboxIds")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime>("SentAt")
.HasColumnType("datetime2");
b.Property<int>("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 => modelBuilder.Entity("GreenHome.Domain.Device", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -40,20 +87,18 @@ namespace GreenHome.Infrastructure.Migrations
.HasMaxLength(250) .HasMaxLength(250)
.HasColumnType("nvarchar(250)"); .HasColumnType("nvarchar(250)");
b.Property<string>("Mobile")
.HasColumnType("nvarchar(max)");
b.Property<string>("NeshanLocation") b.Property<string>("NeshanLocation")
.IsRequired() .IsRequired()
.HasMaxLength(80) .HasMaxLength(80)
.HasColumnType("nvarchar(80)"); .HasColumnType("nvarchar(80)");
b.Property<string>("Owner") b.Property<int>("UserId")
.IsRequired() .HasColumnType("int");
.HasColumnType("nvarchar(max)");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Devices", (string)null); b.ToTable("Devices", (string)null);
}); });
@@ -112,6 +157,21 @@ namespace GreenHome.Infrastructure.Migrations
b.ToTable("DeviceSettings", (string)null); b.ToTable("DeviceSettings", (string)null);
}); });
modelBuilder.Entity("GreenHome.Domain.DeviceUser", b =>
{
b.Property<int>("DeviceId")
.HasColumnType("int");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("DeviceId", "UserId");
b.HasIndex("UserId");
b.ToTable("DeviceUsers", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.TelemetryRecord", b => modelBuilder.Entity("GreenHome.Domain.TelemetryRecord", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -161,6 +221,113 @@ namespace GreenHome.Infrastructure.Migrations
b.ToTable("Telemetry", (string)null); b.ToTable("Telemetry", (string)null);
}); });
modelBuilder.Entity("GreenHome.Domain.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Family")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("datetime2");
b.Property<string>("Mobile")
.IsRequired()
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("Role")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Mobile")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.VerificationCode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<bool>("IsUsed")
.HasColumnType("bit");
b.Property<string>("Mobile")
.IsRequired()
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<DateTime?>("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 => modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b =>
{ {
b.HasOne("GreenHome.Domain.Device", "Device") b.HasOne("GreenHome.Domain.Device", "Device")
@@ -171,6 +338,35 @@ namespace GreenHome.Infrastructure.Migrations
b.Navigation("Device"); 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 #pragma warning restore 612, 618
} }
} }

View File

@@ -22,7 +22,12 @@ public sealed class TelemetryService : ITelemetryService
var entity = mapper.Map<Domain.TelemetryRecord>(dto); var entity = mapper.Map<Domain.TelemetryRecord>(dto);
if (!string.IsNullOrEmpty(dto.DeviceName)) 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 dt = dto.TimestampUtc;
var py = PersianCalendar.GetYear(dt); var py = PersianCalendar.GetYear(dt);

View File

@@ -0,0 +1,4 @@
public class VoiceCallService
{
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,26 @@
using static GreenHome.Sms.Ippanel.IppanelSmsService;
namespace GreenHome.Sms.Ippanel;
/// <summary>
/// Interface for SMS sending service
/// </summary>
public interface ISmsService
{
/// <summary>
/// Sends a webservice SMS message
/// </summary>
/// <param name="request">SMS request details</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>SMS response with bulk ID</returns>
Task<IppanelApiResponse<IppanelData>?> SendWebserviceSmsAsync(WebserviceSmsRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a pattern SMS message
/// </summary>
/// <param name="request">Pattern SMS request details</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>SMS response with bulk ID</returns>
Task<IppanelApiResponse<IppanelData>?> SendPatternSmsAsync(PatternSmsRequest request, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,23 @@
namespace GreenHome.Sms.Ippanel;
/// <summary>
/// Configuration options for IPPanel SMS service
/// </summary>
public sealed class IppanelSmsOptions
{
/// <summary>
/// IPPanel API base URL
/// </summary>
public string BaseUrl { get; set; } = "https://edge.ippanel.com/v1";
/// <summary>
/// IPPanel authorization token
/// </summary>
public required string AuthorizationToken { get; set; }
/// <summary>
/// Default sender number for webservice SMS
/// </summary>
public string? DefaultSender { get; set; }
}

View File

@@ -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;
/// <summary>
/// IPPanel SMS service implementation
/// </summary>
public sealed class IppanelSmsService : ISmsService
{
private readonly HttpClient httpClient;
private readonly IppanelSmsOptions options;
private readonly ILogger<IppanelSmsService> logger;
public IppanelSmsService(
HttpClient httpClient,
IppanelSmsOptions options,
ILogger<IppanelSmsService> logger)
{
this.httpClient = httpClient;
this.options = options;
this.logger = logger;
}
public async Task<IppanelApiResponse<IppanelData>?> 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<IppanelApiResponse<IppanelData>>(
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<IppanelApiResponse<IppanelData>?> 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<IppanelApiResponse<IppanelData>>(
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<string, string> dictionary)
{
var type = dictionary.GetType();
var obj = new System.Dynamic.ExpandoObject();
var dict = obj as IDictionary<string, object>;
foreach (var item in dictionary)
{
dict.Add(item.Key, item.Value);
}
return obj;
}
public class IppanelApiResponse<T>
{
[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<object> MessageParameters { get; set; } = new List<object>();
[JsonPropertyName("message_code")]
public string? MessageCode { get; set; }
[JsonPropertyName("errors")]
public Dictionary<string, List<string>> Errors { get; set; } = new Dictionary<string, List<string>>();
}
// Specific data models for different response types
public class IppanelData
{
[JsonPropertyName("message_outbox_ids")]
public List<long> MessageOutboxIds { get; set; } = new List<long>();
}
}

View File

@@ -0,0 +1,63 @@
namespace GreenHome.Sms.Ippanel;
// SMS DTOs
public sealed class WebserviceSmsRequest
{
/// <summary>
/// Sender number (must be registered in IPPanel)
/// </summary>
public string? Sender { get; set; }
/// <summary>
/// Recipient phone number(s) - comma separated for multiple recipients
/// </summary>
public required string Recipient { get; set; }
/// <summary>
/// SMS message content
/// </summary>
public required string Message { get; set; }
}
public sealed class PatternSmsRequest
{
/// <summary>
/// Pattern code (must be registered in IPPanel)
/// </summary>
public required string PatternCode { get; set; }
/// <summary>
/// Recipient phone number(s) - comma separated for multiple recipients
/// Note: Pattern SMS typically supports single recipient per request
/// </summary>
public required List<string> Recipients { get; set; }
/// <summary>
/// Pattern variables as key-value pairs
/// </summary>
public required Dictionary<string, string> Variables { get; set; }
/// <summary>
/// Optional sender/originator number (if not provided, uses default from configuration)
/// </summary>
public string? Originator { get; set; }
}
public sealed class SmsResponse
{
/// <summary>
/// Bulk ID for tracking the SMS
/// </summary>
public required List<long> BulkId { get; set; }
/// <summary>
/// Response status
/// </summary>
public required string Status { get; set; }
/// <summary>
/// Response message
/// </summary>
public string? Message { get; set; }
}

View File

@@ -0,0 +1,130 @@
# GreenHome.Sms.Ippanel
سرویس ارسال پیامک IPPanel برای پروژه GreenHome
## نصب و راه‌اندازی
### 1. اضافه کردن Reference به پروژه
در فایل `.csproj` پروژه خود:
```xml
<ItemGroup>
<ProjectReference Include="..\GreenHome.Sms.Ippanel\GreenHome.Sms.Ippanel.csproj" />
</ItemGroup>
```
### 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<string, string>
{
{ "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` باقی می‌مانند و نیازی به تغییر کدهای استفاده‌کننده نیست.

View File

@@ -0,0 +1,101 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace GreenHome.Sms.Ippanel;
/// <summary>
/// Extension methods for registering IPPanel SMS service
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds IPPanel SMS service to the service collection
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configuration">Configuration section for IppanelSms</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddIppanelSms(
this IServiceCollection services,
IConfiguration configuration)
{
return services.AddIppanelSms(configuration.GetSection("IppanelSms"));
}
/// <summary>
/// Adds IPPanel SMS service to the service collection
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configurationSection">Configuration section for IppanelSms</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddIppanelSms(
this IServiceCollection services,
IConfigurationSection? configurationSection = null)
{
// Configure options
IppanelSmsOptions? options = null;
if (configurationSection != null)
{
options = configurationSection.Get<IppanelSmsOptions>();
}
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<ISmsService, IppanelSmsService>(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;
}
/// <summary>
/// Adds IPPanel SMS service to the service collection with explicit options
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configureOptions">Action to configure options</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddIppanelSms(
this IServiceCollection services,
Action<IppanelSmsOptions> 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<ISmsService, IppanelSmsService>(client =>
{
client.BaseAddress = new Uri(options.BaseUrl);
client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", options.AuthorizationToken);
client.Timeout = TimeSpan.FromSeconds(30);
});
return services;
}
}

View File

@@ -0,0 +1,18 @@
namespace GreenHome.VoiceCall.Avanak;
/// <summary>
/// Configuration options for Avanak Voice Call service
/// </summary>
public sealed class AvanakVoiceCallOptions
{
/// <summary>
/// Avanak API base URL
/// </summary>
public string BaseUrl { get; set; } = "https://portal.avanak.ir/Rest";
/// <summary>
/// Avanak authorization token
/// </summary>
public required string Token { get; set; }
}

View File

@@ -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;
/// <summary>
/// Avanak Voice Call service implementation
/// </summary>
public sealed class AvanakVoiceCallService : IVoiceCallService
{
private readonly HttpClient httpClient;
private readonly AvanakVoiceCallOptions options;
private readonly ILogger<AvanakVoiceCallService> logger;
public AvanakVoiceCallService(
HttpClient httpClient,
AvanakVoiceCallOptions options,
ILogger<AvanakVoiceCallService> logger)
{
this.httpClient = httpClient;
this.options = options;
this.logger = logger;
}
/// <inheritdoc/>
public async Task<AccountStatusResponse?> GetAccountStatusAsync(CancellationToken cancellationToken = default)
{
try
{
var response = await httpClient.PostAsync(
"AccountStatus",
null,
cancellationToken);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<AccountStatusResponse>(
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;
}
}
/// <inheritdoc/>
public async Task<GenerateTTSResponse?> GenerateTTSAsync(GenerateTTSRequest request, CancellationToken cancellationToken = default)
{
try
{
var response = await httpClient.PostAsJsonAsync(
"GenerateTTS",
request,
cancellationToken);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<GenerateTTSResponse>(
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;
}
}
/// <inheritdoc/>
public async Task<QuickSendResponse?> QuickSendAsync(QuickSendRequest request, CancellationToken cancellationToken = default)
{
try
{
var response = await httpClient.PostAsJsonAsync(
"QuickSend",
request,
cancellationToken);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<QuickSendResponse>(
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;
}
}
/// <inheritdoc/>
public async Task<QuickSendWithTTSResponse?> QuickSendWithTTSAsync(QuickSendWithTTSRequest request, CancellationToken cancellationToken = default)
{
try
{
// Build form data according to Avanak API documentation
var formData = new List<KeyValuePair<string, string>>
{
new("Text", request.Text),
new("Number", request.Number)
};
if (request.Vote.HasValue)
{
formData.Add(new KeyValuePair<string, string>("Vote", request.Vote.Value.ToString().ToLower()));
}
if (request.ServerID.HasValue)
{
formData.Add(new KeyValuePair<string, string>("ServerID", request.ServerID.Value.ToString()));
}
if (!string.IsNullOrWhiteSpace(request.CallFromMobile))
{
formData.Add(new KeyValuePair<string, string>("CallFromMobile", request.CallFromMobile));
}
if (request.RecordVoice.HasValue)
{
formData.Add(new KeyValuePair<string, string>("RecordVoice", request.RecordVoice.Value.ToString().ToLower()));
}
if (request.RecordVoiceDuration.HasValue)
{
formData.Add(new KeyValuePair<string, string>("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<QuickSendWithTTSResponse>(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;
}
}
/// <inheritdoc/>
public async Task<GetQuickSendResponse?> GetQuickSendStatusAsync(GetQuickSendRequest request, CancellationToken cancellationToken = default)
{
try
{
var response = await httpClient.PostAsJsonAsync(
"GetQuickSend",
request,
cancellationToken);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<GetQuickSendResponse>(
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;
}
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageReference Include="System.Text.Json" Version="9.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,47 @@
namespace GreenHome.VoiceCall.Avanak;
/// <summary>
/// Interface for Avanak Voice Call service
/// </summary>
public interface IVoiceCallService
{
/// <summary>
/// Gets account status
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Account status response</returns>
Task<AccountStatusResponse?> GetAccountStatusAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Generates TTS (Text-to-Speech) audio from text
/// </summary>
/// <param name="request">TTS generation request</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>TTS generation response</returns>
Task<GenerateTTSResponse?> GenerateTTSAsync(GenerateTTSRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Sends quick voice call with uploaded audio file
/// </summary>
/// <param name="request">Quick send request</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Quick send response</returns>
Task<QuickSendResponse?> QuickSendAsync(QuickSendRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Sends quick voice call with TTS (Text-to-Speech)
/// </summary>
/// <param name="request">Quick send with TTS request</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Quick send with TTS response</returns>
Task<QuickSendWithTTSResponse?> QuickSendWithTTSAsync(QuickSendWithTTSRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Gets quick send status
/// </summary>
/// <param name="request">Get quick send request</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Quick send status response</returns>
Task<GetQuickSendResponse?> GetQuickSendStatusAsync(GetQuickSendRequest request, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,263 @@
using System.Text.Json.Serialization;
namespace GreenHome.VoiceCall.Avanak;
/// <summary>
/// Request model for AccountStatus method
/// </summary>
public sealed class AccountStatusRequest
{
// No parameters needed for AccountStatus
}
/// <summary>
/// Response model for AccountStatus method
/// </summary>
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; }
}
/// <summary>
/// Account status data
/// </summary>
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; }
}
/// <summary>
/// Request model for GenerateTTS method
/// </summary>
public sealed class GenerateTTSRequest
{
/// <summary>
/// Text to convert to speech
/// </summary>
[JsonPropertyName("text")]
public required string Text { get; set; }
/// <summary>
/// Voice type (optional)
/// </summary>
[JsonPropertyName("voiceType")]
public string? VoiceType { get; set; }
}
/// <summary>
/// Response model for GenerateTTS method
/// </summary>
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; }
}
/// <summary>
/// GenerateTTS data
/// </summary>
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; }
}
/// <summary>
/// Request model for QuickSend method
/// </summary>
public sealed class QuickSendRequest
{
/// <summary>
/// Recipient phone number
/// </summary>
[JsonPropertyName("recipient")]
public required string Recipient { get; set; }
/// <summary>
/// Message ID (uploaded audio file ID)
/// </summary>
[JsonPropertyName("messageId")]
public required string MessageId { get; set; }
}
/// <summary>
/// Response model for QuickSend method
/// </summary>
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; }
}
/// <summary>
/// QuickSend data
/// </summary>
public sealed class QuickSendData
{
[JsonPropertyName("quickSendId")]
public string? QuickSendId { get; set; }
[JsonPropertyName("trackingId")]
public string? TrackingId { get; set; }
}
/// <summary>
/// Request model for QuickSendWithTTS method
/// </summary>
public sealed class QuickSendWithTTSRequest
{
/// <summary>
/// Text to convert to speech and send (required)
/// </summary>
[JsonPropertyName("Text")]
public required string Text { get; set; }
/// <summary>
/// Phone number (required)
/// </summary>
[JsonPropertyName("Number")]
public required string Number { get; set; }
/// <summary>
/// Enable voting option (optional)
/// </summary>
[JsonPropertyName("Vote")]
public bool? Vote { get; set; }
/// <summary>
/// Server ID (optional, default: 0)
/// </summary>
[JsonPropertyName("ServerID")]
public int? ServerID { get; set; }
/// <summary>
/// Mobile number to add at the end of voice (optional)
/// </summary>
[JsonPropertyName("CallFromMobile")]
public string? CallFromMobile { get; set; }
/// <summary>
/// Record voice (optional)
/// </summary>
[JsonPropertyName("RecordVoice")]
public bool? RecordVoice { get; set; }
/// <summary>
/// Record voice duration (optional)
/// </summary>
[JsonPropertyName("RecordVoiceDuration")]
public short? RecordVoiceDuration { get; set; }
}
/// <summary>
/// Response model for QuickSendWithTTS method
/// ReturnValue > 0 means success and contains the quickSendId
/// </summary>
public sealed class QuickSendWithTTSResponse
{
/// <summary>
/// Return value: > 0 means success (contains quickSendId), negative values indicate errors
/// </summary>
[JsonPropertyName("ReturnValue")]
public int ReturnValue { get; set; }
/// <summary>
/// QuickSendId (when ReturnValue > 0)
/// </summary>
public long QuickSendId => ReturnValue > 0 ? ReturnValue : 0;
/// <summary>
/// Indicates if the operation was successful
/// </summary>
public bool IsSuccess => ReturnValue > 0;
}
/// <summary>
/// Request model for GetQuickSend method
/// </summary>
public sealed class GetQuickSendRequest
{
/// <summary>
/// QuickSend ID or Tracking ID
/// </summary>
[JsonPropertyName("quickSendId")]
public required string QuickSendId { get; set; }
}
/// <summary>
/// Response model for GetQuickSend method
/// </summary>
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; }
}
/// <summary>
/// GetQuickSend data
/// </summary>
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; }
}

View File

@@ -0,0 +1,110 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace GreenHome.VoiceCall.Avanak;
/// <summary>
/// Extension methods for registering Avanak Voice Call service
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Avanak Voice Call service to the service collection
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configuration">Configuration section for AvanakVoiceCall</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddAvanakVoiceCall(
this IServiceCollection services,
IConfiguration configuration)
{
return services.AddAvanakVoiceCall(configuration.GetSection("AvanakVoiceCall"));
}
/// <summary>
/// Adds Avanak Voice Call service to the service collection
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configurationSection">Configuration section for AvanakVoiceCall</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddAvanakVoiceCall(
this IServiceCollection services,
IConfigurationSection? configurationSection = null)
{
// Configure options
AvanakVoiceCallOptions? options = null;
if (configurationSection != null)
{
options = configurationSection.Get<AvanakVoiceCallOptions>();
}
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<IVoiceCallService, AvanakVoiceCallService>(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;
}
/// <summary>
/// Adds Avanak Voice Call service to the service collection with explicit options
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configureOptions">Action to configure options</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddAvanakVoiceCall(
this IServiceCollection services,
Action<AvanakVoiceCallOptions> 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<IVoiceCallService, AvanakVoiceCallService>(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;
}
}

View File

@@ -11,6 +11,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreenHome.Infrastructure",
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreenHome.Api", "GreenHome.Api\GreenHome.Api.csproj", "{75E498D4-5D04-4F63-A1A5-5D851FA40C74}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreenHome.Api", "GreenHome.Api\GreenHome.Api.csproj", "{75E498D4-5D04-4F63-A1A5-5D851FA40C74}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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}.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.ActiveCfg = Release|Any CPU
{75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Release|Any CPU.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE