diff --git a/src/GreenHome.Api/Controllers/AlertLogsController.cs b/src/GreenHome.Api/Controllers/AlertLogsController.cs
new file mode 100644
index 0000000..100b202
--- /dev/null
+++ b/src/GreenHome.Api/Controllers/AlertLogsController.cs
@@ -0,0 +1,62 @@
+using Microsoft.AspNetCore.Mvc;
+using GreenHome.Application;
+
+namespace GreenHome.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class AlertLogsController : ControllerBase
+{
+ private readonly IAlertLogService _alertLogService;
+
+ public AlertLogsController(IAlertLogService alertLogService)
+ {
+ _alertLogService = alertLogService;
+ }
+
+ ///
+ /// دریافت لیست لاگهای هشدار با فیلتر و صفحهبندی
+ ///
+ [HttpGet]
+ public async Task>> GetAlertLogs(
+ [FromQuery] int? deviceId,
+ [FromQuery] int? userId,
+ [FromQuery] Domain.AlertType? alertType,
+ [FromQuery] Domain.AlertStatus? status,
+ [FromQuery] DateTime? startDate,
+ [FromQuery] DateTime? endDate,
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 20,
+ CancellationToken cancellationToken = default)
+ {
+ var filter = new AlertLogFilter
+ {
+ DeviceId = deviceId,
+ UserId = userId,
+ AlertType = alertType,
+ Status = status,
+ StartDate = startDate,
+ EndDate = endDate,
+ Page = page,
+ PageSize = pageSize
+ };
+
+ var result = await _alertLogService.GetAlertLogsAsync(filter, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// دریافت جزئیات کامل یک لاگ هشدار
+ ///
+ [HttpGet("{id}")]
+ public async Task> GetAlertLogById(int id, CancellationToken cancellationToken)
+ {
+ var result = await _alertLogService.GetAlertLogByIdAsync(id, cancellationToken);
+ if (result == null)
+ {
+ return NotFound(new { error = $"لاگ هشدار با شناسه {id} یافت نشد" });
+ }
+ return Ok(result);
+ }
+}
+
diff --git a/src/GreenHome.Api/Controllers/ChecklistsController.cs b/src/GreenHome.Api/Controllers/ChecklistsController.cs
new file mode 100644
index 0000000..cb5a328
--- /dev/null
+++ b/src/GreenHome.Api/Controllers/ChecklistsController.cs
@@ -0,0 +1,98 @@
+using Microsoft.AspNetCore.Mvc;
+using GreenHome.Application;
+
+namespace GreenHome.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class ChecklistsController : ControllerBase
+{
+ private readonly IChecklistService _checklistService;
+
+ public ChecklistsController(IChecklistService checklistService)
+ {
+ _checklistService = checklistService;
+ }
+
+ ///
+ /// دریافت چکلیست فعال یک دستگاه
+ ///
+ [HttpGet("active/{deviceId}")]
+ public async Task> GetActiveChecklist(int deviceId, CancellationToken cancellationToken)
+ {
+ var result = await _checklistService.GetActiveChecklistByDeviceIdAsync(deviceId, cancellationToken);
+ if (result == null)
+ {
+ return NotFound(new { error = "چکلیست فعالی برای این دستگاه یافت نشد" });
+ }
+ return Ok(result);
+ }
+
+ ///
+ /// دریافت تمام چکلیستهای یک دستگاه
+ ///
+ [HttpGet("device/{deviceId}")]
+ public async Task>> GetChecklists(int deviceId, CancellationToken cancellationToken)
+ {
+ var result = await _checklistService.GetChecklistsByDeviceIdAsync(deviceId, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// دریافت جزئیات یک چکلیست
+ ///
+ [HttpGet("{id}")]
+ public async Task> GetChecklistById(int id, CancellationToken cancellationToken)
+ {
+ var result = await _checklistService.GetChecklistByIdAsync(id, cancellationToken);
+ if (result == null)
+ {
+ return NotFound(new { error = $"چکلیست با شناسه {id} یافت نشد" });
+ }
+ return Ok(result);
+ }
+
+ ///
+ /// ایجاد چکلیست جدید (چکلیست قبلی غیرفعال میشود)
+ ///
+ [HttpPost]
+ public async Task> CreateChecklist(
+ CreateChecklistRequest request,
+ CancellationToken cancellationToken)
+ {
+ var id = await _checklistService.CreateChecklistAsync(request, cancellationToken);
+ return Ok(new { id, message = "چکلیست با موفقیت ایجاد شد و چکلیست قبلی غیرفعال شد" });
+ }
+
+ ///
+ /// دریافت سابقه تکمیلهای یک چکلیست
+ ///
+ [HttpGet("{checklistId}/completions")]
+ public async Task>> GetCompletions(
+ int checklistId,
+ CancellationToken cancellationToken)
+ {
+ var result = await _checklistService.GetCompletionsByChecklistIdAsync(checklistId, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// ثبت تکمیل چکلیست
+ ///
+ [HttpPost("complete")]
+ public async Task> CompleteChecklist(
+ CompleteChecklistRequest request,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var id = await _checklistService.CompleteChecklistAsync(request, cancellationToken);
+ return Ok(new { id, message = "چکلیست با موفقیت تکمیل شد" });
+ }
+ catch (InvalidOperationException ex)
+ {
+ return NotFound(new { error = ex.Message });
+ }
+ }
+}
+
diff --git a/src/GreenHome.Api/Controllers/DailyReportController.cs b/src/GreenHome.Api/Controllers/DailyReportController.cs
index a97c8ea..be7b6cc 100644
--- a/src/GreenHome.Api/Controllers/DailyReportController.cs
+++ b/src/GreenHome.Api/Controllers/DailyReportController.cs
@@ -73,5 +73,71 @@ public class DailyReportController : ControllerBase
return StatusCode(500, new { error = "خطای سرور در پردازش درخواست" });
}
}
+
+ ///
+ /// دریافت تحلیل هفتگی
+ ///
+ [HttpGet("weekly")]
+ public async Task> GetWeeklyAnalysis(
+ [FromQuery] int deviceId,
+ [FromQuery] string startDate,
+ [FromQuery] string endDate,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var request = new WeeklyAnalysisRequest
+ {
+ DeviceId = deviceId,
+ StartDate = startDate.Trim(),
+ EndDate = endDate.Trim()
+ };
+
+ var result = await _dailyReportService.GetWeeklyAnalysisAsync(request, cancellationToken);
+ return Ok(result);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest(new { error = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting weekly analysis");
+ return StatusCode(500, new { error = "خطا در دریافت تحلیل هفتگی" });
+ }
+ }
+
+ ///
+ /// دریافت تحلیل ماهانه
+ ///
+ [HttpGet("monthly")]
+ public async Task> GetMonthlyAnalysis(
+ [FromQuery] int deviceId,
+ [FromQuery] int year,
+ [FromQuery] int month,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var request = new MonthlyAnalysisRequest
+ {
+ DeviceId = deviceId,
+ Year = year,
+ Month = month
+ };
+
+ var result = await _dailyReportService.GetMonthlyAnalysisAsync(request, cancellationToken);
+ return Ok(result);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest(new { error = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting monthly analysis");
+ return StatusCode(500, new { error = "خطا در دریافت تحلیل ماهانه" });
+ }
+ }
}
diff --git a/src/GreenHome.Api/Controllers/DevicePostsController.cs b/src/GreenHome.Api/Controllers/DevicePostsController.cs
new file mode 100644
index 0000000..8de3e35
--- /dev/null
+++ b/src/GreenHome.Api/Controllers/DevicePostsController.cs
@@ -0,0 +1,212 @@
+using Microsoft.AspNetCore.Mvc;
+using GreenHome.Application;
+
+namespace GreenHome.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class DevicePostsController : ControllerBase
+{
+ private readonly IDevicePostService _postService;
+ private readonly ILogger _logger;
+ private readonly IWebHostEnvironment _environment;
+
+ public DevicePostsController(
+ IDevicePostService postService,
+ ILogger logger,
+ IWebHostEnvironment environment)
+ {
+ _postService = postService;
+ _logger = logger;
+ _environment = environment;
+ }
+
+ ///
+ /// دریافت پستهای گروه مجازی دستگاه (تایملاین)
+ ///
+ [HttpGet]
+ public async Task>> GetPosts(
+ [FromQuery] int deviceId,
+ [FromQuery] int? authorUserId,
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 20,
+ CancellationToken cancellationToken = default)
+ {
+ var filter = new DevicePostFilter
+ {
+ DeviceId = deviceId,
+ AuthorUserId = authorUserId,
+ Page = page,
+ PageSize = pageSize
+ };
+
+ var result = await _postService.GetPostsAsync(filter, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// دریافت جزئیات یک پست
+ ///
+ [HttpGet("{id}")]
+ public async Task> GetPostById(int id, CancellationToken cancellationToken)
+ {
+ var result = await _postService.GetPostByIdAsync(id, cancellationToken);
+ if (result == null)
+ {
+ return NotFound(new { error = $"پست با شناسه {id} یافت نشد" });
+ }
+ return Ok(result);
+ }
+
+ ///
+ /// ایجاد پست جدید در گروه مجازی
+ ///
+ [HttpPost]
+ public async Task> CreatePost(
+ CreateDevicePostRequest request,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var id = await _postService.CreatePostAsync(request, cancellationToken);
+ return Ok(new { id, message = "پست با موفقیت ایجاد شد" });
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ return Unauthorized(new { error = ex.Message });
+ }
+ }
+
+ ///
+ /// ویرایش پست
+ ///
+ [HttpPut]
+ public async Task UpdatePost(
+ UpdateDevicePostRequest request,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ await _postService.UpdatePostAsync(request, cancellationToken);
+ return Ok(new { message = "پست با موفقیت ویرایش شد" });
+ }
+ catch (InvalidOperationException ex)
+ {
+ return NotFound(new { error = ex.Message });
+ }
+ }
+
+ ///
+ /// حذف پست
+ ///
+ [HttpDelete("{id}")]
+ public async Task DeletePost(int id, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await _postService.DeletePostAsync(id, cancellationToken);
+ return Ok(new { message = "پست با موفقیت حذف شد" });
+ }
+ catch (InvalidOperationException ex)
+ {
+ return NotFound(new { error = ex.Message });
+ }
+ }
+
+ ///
+ /// آپلود تصویر برای پست
+ ///
+ [HttpPost("{postId}/images")]
+ [Consumes("multipart/form-data")]
+ public async Task> UploadImage(
+ int postId,
+ [FromForm] IFormFile file,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (file == null || file.Length == 0)
+ {
+ return BadRequest(new { error = "فایل انتخاب نشده است" });
+ }
+
+ // Validate file type
+ var allowedTypes = new[] { "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp" };
+ if (!allowedTypes.Contains(file.ContentType.ToLower()))
+ {
+ return BadRequest(new { error = "فقط فایلهای تصویری مجاز هستند" });
+ }
+
+ // Validate file size (max 5MB)
+ if (file.Length > 5 * 1024 * 1024)
+ {
+ return BadRequest(new { error = "حجم فایل نباید بیشتر از 5 مگابایت باشد" });
+ }
+
+ // Create upload directory
+ var uploadsFolder = Path.Combine(_environment.WebRootPath ?? "wwwroot", "uploads", "posts");
+ Directory.CreateDirectory(uploadsFolder);
+
+ // Generate unique filename
+ var fileName = $"{Guid.NewGuid()}_{Path.GetFileName(file.FileName)}";
+ var filePath = Path.Combine(uploadsFolder, fileName);
+
+ // Save file
+ using (var stream = new FileStream(filePath, FileMode.Create))
+ {
+ await file.CopyToAsync(stream, cancellationToken);
+ }
+
+ // Save to database
+ var relativePath = $"/uploads/posts/{fileName}";
+ var imageId = await _postService.AddImageToPostAsync(
+ postId,
+ file.FileName,
+ relativePath,
+ file.ContentType,
+ file.Length,
+ cancellationToken);
+
+ _logger.LogInformation("Image uploaded for post {PostId}: {ImageId}", postId, imageId);
+
+ return Ok(new { imageId, filePath = relativePath, message = "تصویر با موفقیت آپلود شد" });
+ }
+ catch (InvalidOperationException ex)
+ {
+ return NotFound(new { error = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error uploading image for post {PostId}", postId);
+ return StatusCode(500, new { error = "خطا در آپلود تصویر" });
+ }
+ }
+
+ ///
+ /// حذف تصویر از پست
+ ///
+ [HttpDelete("images/{imageId}")]
+ public async Task DeleteImage(int imageId, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await _postService.DeleteImageAsync(imageId, cancellationToken);
+ return Ok(new { message = "تصویر با موفقیت حذف شد" });
+ }
+ catch (InvalidOperationException ex)
+ {
+ return NotFound(new { error = ex.Message });
+ }
+ }
+
+ ///
+ /// بررسی دسترسی کاربر به دستگاه
+ ///
+ [HttpGet("access/{userId}/{deviceId}")]
+ public async Task> CheckAccess(int userId, int deviceId, CancellationToken cancellationToken)
+ {
+ var hasAccess = await _postService.CanUserAccessDeviceAsync(userId, deviceId, cancellationToken);
+ return Ok(new { hasAccess });
+ }
+}
+
diff --git a/src/GreenHome.Api/Controllers/MonthlyReportController.cs b/src/GreenHome.Api/Controllers/MonthlyReportController.cs
new file mode 100644
index 0000000..ba0f376
--- /dev/null
+++ b/src/GreenHome.Api/Controllers/MonthlyReportController.cs
@@ -0,0 +1,62 @@
+using Microsoft.AspNetCore.Mvc;
+using GreenHome.Application;
+
+namespace GreenHome.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class MonthlyReportController : ControllerBase
+{
+ private readonly IMonthlyReportService _monthlyReportService;
+ private readonly ILogger _logger;
+
+ public MonthlyReportController(
+ IMonthlyReportService monthlyReportService,
+ ILogger logger)
+ {
+ _monthlyReportService = monthlyReportService;
+ _logger = logger;
+ }
+
+ ///
+ /// دریافت گزارش آماری ماهانه
+ ///
+ [HttpGet]
+ public async Task> GetMonthlyReport(
+ [FromQuery] int deviceId,
+ [FromQuery] int year,
+ [FromQuery] int month,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (deviceId <= 0)
+ {
+ return BadRequest(new { error = "شناسه دستگاه نامعتبر است" });
+ }
+
+ if (month < 1 || month > 12)
+ {
+ return BadRequest(new { error = "ماه باید بین 1 تا 12 باشد" });
+ }
+
+ var result = await _monthlyReportService.GetMonthlyReportAsync(deviceId, year, month, cancellationToken);
+
+ _logger.LogInformation(
+ "گزارش ماهانه برای دستگاه {DeviceId} و ماه {Month}/{Year} ایجاد شد",
+ deviceId, month, year);
+
+ return Ok(result);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return NotFound(new { error = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error generating monthly report");
+ return StatusCode(500, new { error = "خطا در ایجاد گزارش ماهانه" });
+ }
+ }
+}
+
diff --git a/src/GreenHome.Api/Controllers/PowerOutageController.cs b/src/GreenHome.Api/Controllers/PowerOutageController.cs
new file mode 100644
index 0000000..b98fa67
--- /dev/null
+++ b/src/GreenHome.Api/Controllers/PowerOutageController.cs
@@ -0,0 +1,60 @@
+using Microsoft.AspNetCore.Mvc;
+using GreenHome.Application;
+
+namespace GreenHome.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class PowerOutageController : ControllerBase
+{
+ private readonly IAlertService _alertService;
+ private readonly ILogger _logger;
+
+ public PowerOutageController(
+ IAlertService alertService,
+ ILogger logger)
+ {
+ _alertService = alertService;
+ _logger = logger;
+ }
+
+ ///
+ /// ارسال هشدار قطع برق برای یک دستگاه
+ ///
+ /// شناسه دستگاه
+ /// Cancellation token
+ /// نتیجه عملیات
+ [HttpPost]
+ public async Task SendPowerOutageAlert(
+ [FromQuery] int deviceId,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (deviceId <= 0)
+ {
+ return BadRequest(new { error = "شناسه دستگاه نامعتبر است" });
+ }
+
+ await _alertService.SendPowerOutageAlertAsync(deviceId, cancellationToken);
+
+ _logger.LogInformation("Power outage alert processed for device {DeviceId}", deviceId);
+
+ return Ok(new {
+ success = true,
+ message = "هشدار قطع برق با موفقیت ارسال شد"
+ });
+ }
+ catch (InvalidOperationException ex)
+ {
+ _logger.LogWarning(ex, "Invalid operation for power outage alert: DeviceId={DeviceId}", deviceId);
+ return BadRequest(new { error = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error sending power outage alert: DeviceId={DeviceId}", deviceId);
+ return StatusCode(500, new { error = "خطا در ارسال هشدار قطع برق" });
+ }
+ }
+}
+
diff --git a/src/GreenHome.Api/Controllers/UserDailyReportsController.cs b/src/GreenHome.Api/Controllers/UserDailyReportsController.cs
new file mode 100644
index 0000000..002aa6b
--- /dev/null
+++ b/src/GreenHome.Api/Controllers/UserDailyReportsController.cs
@@ -0,0 +1,210 @@
+using Microsoft.AspNetCore.Mvc;
+using GreenHome.Application;
+
+namespace GreenHome.Api.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class UserDailyReportsController : ControllerBase
+{
+ private readonly IUserDailyReportService _reportService;
+ private readonly ILogger _logger;
+ private readonly IWebHostEnvironment _environment;
+
+ public UserDailyReportsController(
+ IUserDailyReportService reportService,
+ ILogger logger,
+ IWebHostEnvironment environment)
+ {
+ _reportService = reportService;
+ _logger = logger;
+ _environment = environment;
+ }
+
+ ///
+ /// دریافت لیست گزارشهای روزانه کاربران با فیلتر
+ ///
+ [HttpGet]
+ public async Task>> GetReports(
+ [FromQuery] int? deviceId,
+ [FromQuery] int? userId,
+ [FromQuery] string? persianDate,
+ [FromQuery] int? year,
+ [FromQuery] int? month,
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 20,
+ CancellationToken cancellationToken = default)
+ {
+ var filter = new UserDailyReportFilter
+ {
+ DeviceId = deviceId,
+ UserId = userId,
+ PersianDate = persianDate,
+ Year = year,
+ Month = month,
+ Page = page,
+ PageSize = pageSize
+ };
+
+ var result = await _reportService.GetReportsAsync(filter, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// دریافت جزئیات یک گزارش روزانه
+ ///
+ [HttpGet("{id}")]
+ public async Task> GetReportById(int id, CancellationToken cancellationToken)
+ {
+ var result = await _reportService.GetReportByIdAsync(id, cancellationToken);
+ if (result == null)
+ {
+ return NotFound(new { error = $"گزارش با شناسه {id} یافت نشد" });
+ }
+ return Ok(result);
+ }
+
+ ///
+ /// ایجاد گزارش روزانه جدید
+ ///
+ [HttpPost]
+ public async Task> CreateReport(
+ CreateUserDailyReportRequest request,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var id = await _reportService.CreateReportAsync(request, cancellationToken);
+ return Ok(new { id, message = "گزارش با موفقیت ایجاد شد" });
+ }
+ catch (ArgumentException ex)
+ {
+ return BadRequest(new { error = ex.Message });
+ }
+ }
+
+ ///
+ /// ویرایش گزارش روزانه
+ ///
+ [HttpPut]
+ public async Task UpdateReport(
+ UpdateUserDailyReportRequest request,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ await _reportService.UpdateReportAsync(request, cancellationToken);
+ return Ok(new { message = "گزارش با موفقیت ویرایش شد" });
+ }
+ catch (InvalidOperationException ex)
+ {
+ return NotFound(new { error = ex.Message });
+ }
+ }
+
+ ///
+ /// حذف گزارش روزانه
+ ///
+ [HttpDelete("{id}")]
+ public async Task DeleteReport(int id, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await _reportService.DeleteReportAsync(id, cancellationToken);
+ return Ok(new { message = "گزارش با موفقیت حذف شد" });
+ }
+ catch (InvalidOperationException ex)
+ {
+ return NotFound(new { error = ex.Message });
+ }
+ }
+
+ ///
+ /// آپلود تصویر برای گزارش روزانه
+ ///
+ [HttpPost("{reportId}/images")]
+ [Consumes("multipart/form-data")]
+ public async Task> UploadImage(
+ int reportId,
+ [FromForm] IFormFile file,
+ [FromForm] string? description,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (file == null || file.Length == 0)
+ {
+ return BadRequest(new { error = "فایل انتخاب نشده است" });
+ }
+
+ // Validate file type
+ var allowedTypes = new[] { "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp" };
+ if (!allowedTypes.Contains(file.ContentType.ToLower()))
+ {
+ return BadRequest(new { error = "فقط فایلهای تصویری مجاز هستند" });
+ }
+
+ // Validate file size (max 5MB)
+ if (file.Length > 5 * 1024 * 1024)
+ {
+ return BadRequest(new { error = "حجم فایل نباید بیشتر از 5 مگابایت باشد" });
+ }
+
+ // Create upload directory
+ var uploadsFolder = Path.Combine(_environment.WebRootPath ?? "wwwroot", "uploads", "reports");
+ Directory.CreateDirectory(uploadsFolder);
+
+ // Generate unique filename
+ var fileName = $"{Guid.NewGuid()}_{Path.GetFileName(file.FileName)}";
+ var filePath = Path.Combine(uploadsFolder, fileName);
+
+ // Save file
+ using (var stream = new FileStream(filePath, FileMode.Create))
+ {
+ await file.CopyToAsync(stream, cancellationToken);
+ }
+
+ // Save to database
+ var relativePath = $"/uploads/reports/{fileName}";
+ var imageId = await _reportService.AddImageToReportAsync(
+ reportId,
+ file.FileName,
+ relativePath,
+ file.ContentType,
+ file.Length,
+ description,
+ cancellationToken);
+
+ _logger.LogInformation("Image uploaded for report {ReportId}: {ImageId}", reportId, imageId);
+
+ return Ok(new { imageId, filePath = relativePath, message = "تصویر با موفقیت آپلود شد" });
+ }
+ catch (InvalidOperationException ex)
+ {
+ return NotFound(new { error = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error uploading image for report {ReportId}", reportId);
+ return StatusCode(500, new { error = "خطا در آپلود تصویر" });
+ }
+ }
+
+ ///
+ /// حذف تصویر از گزارش
+ ///
+ [HttpDelete("images/{imageId}")]
+ public async Task DeleteImage(int imageId, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await _reportService.DeleteImageAsync(imageId, cancellationToken);
+ return Ok(new { message = "تصویر با موفقیت حذف شد" });
+ }
+ catch (InvalidOperationException ex)
+ {
+ return NotFound(new { error = ex.Message });
+ }
+ }
+}
+
diff --git a/src/GreenHome.Api/GreenHome.Api.csproj b/src/GreenHome.Api/GreenHome.Api.csproj
index 054198c..14c7f53 100644
--- a/src/GreenHome.Api/GreenHome.Api.csproj
+++ b/src/GreenHome.Api/GreenHome.Api.csproj
@@ -13,7 +13,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/src/GreenHome.Api/Program.cs b/src/GreenHome.Api/Program.cs
index 9c3c8f7..e1fa0e3 100644
--- a/src/GreenHome.Api/Program.cs
+++ b/src/GreenHome.Api/Program.cs
@@ -5,12 +5,13 @@ using GreenHome.Infrastructure;
using GreenHome.Sms.Ippanel;
using GreenHome.VoiceCall.Avanak;
using Microsoft.EntityFrameworkCore;
+using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
-builder.Services.AddSwaggerGen();
+builder.Services.AddOpenApi();
// Application/Infrastructure DI
builder.Services.AddAutoMapper(typeof(GreenHome.Application.MappingProfile));
@@ -60,6 +61,11 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
// SMS Service Configuration
builder.Services.AddIppanelSms(builder.Configuration);
@@ -89,11 +95,8 @@ using (var scope = app.Services.CreateScope())
}
// Configure the HTTP request pipeline.
-//if (app.Environment.IsDevelopment())
-{
- app.UseSwagger();
- app.UseSwaggerUI();
-}
+app.MapOpenApi();
+app.MapScalarApiReference();
// HTTPS Redirection فقط در Production
if (!app.Environment.IsDevelopment())
diff --git a/src/GreenHome.Application/ChecklistDtos.cs b/src/GreenHome.Application/ChecklistDtos.cs
new file mode 100644
index 0000000..ffa3739
--- /dev/null
+++ b/src/GreenHome.Application/ChecklistDtos.cs
@@ -0,0 +1,80 @@
+namespace GreenHome.Application;
+
+public sealed class ChecklistDto
+{
+ public int Id { get; set; }
+ public int DeviceId { get; set; }
+ public string DeviceName { get; set; } = string.Empty;
+ public string Title { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public bool IsActive { get; set; }
+ public List Items { get; set; } = new();
+ public DateTime CreatedAt { get; set; }
+ public int CreatedByUserId { get; set; }
+ public string CreatedByUserName { get; set; } = string.Empty;
+}
+
+public sealed class ChecklistItemDto
+{
+ public int Id { get; set; }
+ public string Title { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public int Order { get; set; }
+ public bool IsRequired { get; set; }
+}
+
+public sealed class CreateChecklistRequest
+{
+ public required int DeviceId { get; set; }
+ public required int CreatedByUserId { get; set; }
+ public required string Title { get; set; }
+ public string? Description { get; set; }
+ public required List Items { get; set; }
+}
+
+public sealed class CreateChecklistItemRequest
+{
+ public required string Title { get; set; }
+ public string? Description { get; set; }
+ public int Order { get; set; }
+ public bool IsRequired { get; set; }
+}
+
+public sealed class ChecklistCompletionDto
+{
+ public int Id { get; set; }
+ public int ChecklistId { get; set; }
+ public string ChecklistTitle { get; set; } = string.Empty;
+ public int CompletedByUserId { get; set; }
+ public string CompletedByUserName { get; set; } = string.Empty;
+ public string PersianDate { get; set; } = string.Empty;
+ public List ItemCompletions { get; set; } = new();
+ public string? Notes { get; set; }
+ public DateTime CompletedAt { get; set; }
+}
+
+public sealed class ChecklistItemCompletionDto
+{
+ public int Id { get; set; }
+ public int ChecklistItemId { get; set; }
+ public string ItemTitle { get; set; } = string.Empty;
+ public bool IsChecked { get; set; }
+ public string? Note { get; set; }
+}
+
+public sealed class CompleteChecklistRequest
+{
+ public required int ChecklistId { get; set; }
+ public required int CompletedByUserId { get; set; }
+ public required string PersianDate { get; set; }
+ public required List ItemCompletions { get; set; }
+ public string? Notes { get; set; }
+}
+
+public sealed class CompleteChecklistItemRequest
+{
+ public required int ChecklistItemId { get; set; }
+ public bool IsChecked { get; set; }
+ public string? Note { get; set; }
+}
+
diff --git a/src/GreenHome.Application/DevicePostDtos.cs b/src/GreenHome.Application/DevicePostDtos.cs
new file mode 100644
index 0000000..6fe21fb
--- /dev/null
+++ b/src/GreenHome.Application/DevicePostDtos.cs
@@ -0,0 +1,46 @@
+namespace GreenHome.Application;
+
+public sealed class DevicePostDto
+{
+ public int Id { get; set; }
+ public int DeviceId { get; set; }
+ public int AuthorUserId { get; set; }
+ public string AuthorName { get; set; } = string.Empty;
+ public string AuthorFamily { get; set; } = string.Empty;
+ public string Content { get; set; } = string.Empty;
+ public List Images { get; set; } = new();
+ public DateTime CreatedAt { get; set; }
+ public DateTime? UpdatedAt { get; set; }
+}
+
+public sealed class DevicePostImageDto
+{
+ public int Id { get; set; }
+ public string FileName { get; set; } = string.Empty;
+ public string FilePath { get; set; } = string.Empty;
+ public string ContentType { get; set; } = string.Empty;
+ public long FileSize { get; set; }
+ public DateTime UploadedAt { get; set; }
+}
+
+public sealed class CreateDevicePostRequest
+{
+ public required int DeviceId { get; set; }
+ public required int AuthorUserId { get; set; }
+ public required string Content { get; set; }
+}
+
+public sealed class UpdateDevicePostRequest
+{
+ public required int Id { get; set; }
+ public required string Content { get; set; }
+}
+
+public sealed class DevicePostFilter
+{
+ public required int DeviceId { get; set; }
+ public int? AuthorUserId { get; set; }
+ public int Page { get; set; } = 1;
+ public int PageSize { get; set; } = 20;
+}
+
diff --git a/src/GreenHome.Application/Dtos.cs b/src/GreenHome.Application/Dtos.cs
index add50e0..975cf2c 100644
--- a/src/GreenHome.Application/Dtos.cs
+++ b/src/GreenHome.Application/Dtos.cs
@@ -123,6 +123,11 @@ public sealed class DeviceSettingsDto
public decimal? Latitude { get; set; }
public decimal? Longitude { get; set; }
+ public string ProductType { get; set; } = string.Empty;
+ public int MinimumSmsIntervalMinutes { get; set; } = 15;
+ public int MinimumCallIntervalMinutes { get; set; } = 60;
+ public decimal? AreaSquareMeters { get; set; }
+
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
@@ -202,4 +207,109 @@ public sealed class DailyReportResponse
public int TotalTokens { get; set; }
public DateTime CreatedAt { get; set; }
public bool FromCache { get; set; }
+}
+
+public sealed class WeeklyAnalysisRequest
+{
+ public required int DeviceId { get; set; }
+ public required string StartDate { get; set; } // yyyy/MM/dd
+ public required string EndDate { get; set; } // yyyy/MM/dd
+}
+
+public sealed class MonthlyAnalysisRequest
+{
+ public required int DeviceId { get; set; }
+ public required int Year { get; set; }
+ public required int Month { get; set; }
+}
+
+public sealed class AlertLogDto
+{
+ public int Id { get; set; }
+ public int DeviceId { get; set; }
+ public string DeviceName { get; set; } = string.Empty;
+ public int UserId { get; set; }
+ public string UserName { get; set; } = string.Empty;
+ public string UserMobile { get; set; } = string.Empty;
+ public int? AlertConditionId { get; set; }
+ public Domain.AlertType AlertType { get; set; }
+ public Domain.AlertNotificationType NotificationType { get; set; }
+ public string Message { get; set; } = string.Empty;
+ public Domain.AlertStatus Status { get; set; }
+ public string? ErrorMessage { get; set; }
+ public string PhoneNumber { get; set; } = string.Empty;
+ public DateTime SentAt { get; set; }
+ public long ProcessingTimeMs { get; set; }
+}
+
+public sealed class AlertLogFilter
+{
+ public int? DeviceId { get; set; }
+ public int? UserId { get; set; }
+ public Domain.AlertType? AlertType { get; set; }
+ public Domain.AlertStatus? Status { get; set; }
+ public DateTime? StartDate { get; set; }
+ public DateTime? EndDate { get; set; }
+ public int Page { get; set; } = 1;
+ public int PageSize { get; set; } = 20;
+}
+
+public sealed class UserDailyReportDto
+{
+ public int Id { get; set; }
+ public int DeviceId { get; set; }
+ public string DeviceName { get; set; } = string.Empty;
+ public int UserId { get; set; }
+ public string UserName { get; set; } = string.Empty;
+ public string UserFamily { get; set; } = string.Empty;
+ public string PersianDate { get; set; } = string.Empty;
+ public string Title { get; set; } = string.Empty;
+ public string Observations { get; set; } = string.Empty;
+ public string Operations { get; set; } = string.Empty;
+ public string? Notes { get; set; }
+ public List Images { get; set; } = new();
+ public DateTime CreatedAt { get; set; }
+ public DateTime UpdatedAt { get; set; }
+}
+
+public sealed class ReportImageDto
+{
+ public int Id { get; set; }
+ public string FileName { get; set; } = string.Empty;
+ public string FilePath { get; set; } = string.Empty;
+ public string ContentType { get; set; } = string.Empty;
+ public long FileSize { get; set; }
+ public string? Description { get; set; }
+ public DateTime UploadedAt { get; set; }
+}
+
+public sealed class CreateUserDailyReportRequest
+{
+ public required int DeviceId { get; set; }
+ public required int UserId { get; set; }
+ public required string PersianDate { get; set; }
+ public required string Title { get; set; }
+ public required string Observations { get; set; }
+ public required string Operations { get; set; }
+ public string? Notes { get; set; }
+}
+
+public sealed class UpdateUserDailyReportRequest
+{
+ public required int Id { get; set; }
+ public required string Title { get; set; }
+ public required string Observations { get; set; }
+ public required string Operations { get; set; }
+ public string? Notes { get; set; }
+}
+
+public sealed class UserDailyReportFilter
+{
+ public int? DeviceId { get; set; }
+ public int? UserId { get; set; }
+ public string? PersianDate { get; set; }
+ public int? Year { get; set; }
+ public int? Month { get; set; }
+ public int Page { get; set; } = 1;
+ public int PageSize { get; set; } = 20;
}
\ No newline at end of file
diff --git a/src/GreenHome.Application/IAlertLogService.cs b/src/GreenHome.Application/IAlertLogService.cs
new file mode 100644
index 0000000..ee065ed
--- /dev/null
+++ b/src/GreenHome.Application/IAlertLogService.cs
@@ -0,0 +1,9 @@
+namespace GreenHome.Application;
+
+public interface IAlertLogService
+{
+ Task> GetAlertLogsAsync(AlertLogFilter filter, CancellationToken cancellationToken);
+ Task GetAlertLogByIdAsync(int id, CancellationToken cancellationToken);
+ Task CreateAlertLogAsync(AlertLogDto dto, CancellationToken cancellationToken);
+}
+
diff --git a/src/GreenHome.Application/IAlertService.cs b/src/GreenHome.Application/IAlertService.cs
index 30ec9e3..ffd8aae 100644
--- a/src/GreenHome.Application/IAlertService.cs
+++ b/src/GreenHome.Application/IAlertService.cs
@@ -3,5 +3,6 @@ namespace GreenHome.Application;
public interface IAlertService
{
Task CheckAndSendAlertsAsync(int deviceId, TelemetryDto telemetry, CancellationToken cancellationToken);
+ Task SendPowerOutageAlertAsync(int deviceId, CancellationToken cancellationToken);
}
diff --git a/src/GreenHome.Application/IChecklistService.cs b/src/GreenHome.Application/IChecklistService.cs
new file mode 100644
index 0000000..e05fe61
--- /dev/null
+++ b/src/GreenHome.Application/IChecklistService.cs
@@ -0,0 +1,12 @@
+namespace GreenHome.Application;
+
+public interface IChecklistService
+{
+ Task GetActiveChecklistByDeviceIdAsync(int deviceId, CancellationToken cancellationToken);
+ Task> GetChecklistsByDeviceIdAsync(int deviceId, CancellationToken cancellationToken);
+ Task GetChecklistByIdAsync(int id, CancellationToken cancellationToken);
+ Task CreateChecklistAsync(CreateChecklistRequest request, CancellationToken cancellationToken);
+ Task> GetCompletionsByChecklistIdAsync(int checklistId, CancellationToken cancellationToken);
+ Task CompleteChecklistAsync(CompleteChecklistRequest request, CancellationToken cancellationToken);
+}
+
diff --git a/src/GreenHome.Application/IDailyReportService.cs b/src/GreenHome.Application/IDailyReportService.cs
index a022ccf..c2b7a14 100644
--- a/src/GreenHome.Application/IDailyReportService.cs
+++ b/src/GreenHome.Application/IDailyReportService.cs
@@ -9,5 +9,9 @@ public interface IDailyReportService
/// Cancellation token
/// Daily report with AI analysis
Task GetOrCreateDailyReportAsync(DailyReportRequest request, CancellationToken cancellationToken);
+
+ Task GetWeeklyAnalysisAsync(WeeklyAnalysisRequest request, CancellationToken cancellationToken);
+
+ Task GetMonthlyAnalysisAsync(MonthlyAnalysisRequest request, CancellationToken cancellationToken);
}
diff --git a/src/GreenHome.Application/IDevicePostService.cs b/src/GreenHome.Application/IDevicePostService.cs
new file mode 100644
index 0000000..1349a72
--- /dev/null
+++ b/src/GreenHome.Application/IDevicePostService.cs
@@ -0,0 +1,14 @@
+namespace GreenHome.Application;
+
+public interface IDevicePostService
+{
+ Task> GetPostsAsync(DevicePostFilter filter, CancellationToken cancellationToken);
+ Task GetPostByIdAsync(int id, CancellationToken cancellationToken);
+ Task CreatePostAsync(CreateDevicePostRequest request, CancellationToken cancellationToken);
+ Task UpdatePostAsync(UpdateDevicePostRequest request, CancellationToken cancellationToken);
+ Task DeletePostAsync(int id, CancellationToken cancellationToken);
+ Task AddImageToPostAsync(int postId, string fileName, string filePath, string contentType, long fileSize, CancellationToken cancellationToken);
+ Task DeleteImageAsync(int imageId, CancellationToken cancellationToken);
+ Task CanUserAccessDeviceAsync(int userId, int deviceId, CancellationToken cancellationToken);
+}
+
diff --git a/src/GreenHome.Application/IMonthlyReportService.cs b/src/GreenHome.Application/IMonthlyReportService.cs
new file mode 100644
index 0000000..16911a5
--- /dev/null
+++ b/src/GreenHome.Application/IMonthlyReportService.cs
@@ -0,0 +1,45 @@
+namespace GreenHome.Application;
+
+public interface IMonthlyReportService
+{
+ Task GetMonthlyReportAsync(int deviceId, int year, int month, CancellationToken cancellationToken);
+}
+
+public sealed class MonthlyReportDto
+{
+ public int DeviceId { get; set; }
+ public string DeviceName { get; set; } = string.Empty;
+ public int Year { get; set; }
+ public int Month { get; set; }
+
+ // Alert Statistics
+ public int TotalAlerts { get; set; }
+ public int SmsAlerts { get; set; }
+ public int CallAlerts { get; set; }
+ public int SuccessfulAlerts { get; set; }
+ public int FailedAlerts { get; set; }
+ public int PowerOutageAlerts { get; set; }
+
+ // Telemetry Statistics
+ public int TotalTelemetryRecords { get; set; }
+ public decimal AverageTemperature { get; set; }
+ public decimal MinTemperature { get; set; }
+ public decimal MaxTemperature { get; set; }
+ public decimal AverageHumidity { get; set; }
+ public decimal MinHumidity { get; set; }
+ public decimal MaxHumidity { get; set; }
+ public decimal AverageLux { get; set; }
+ public int AverageGasPPM { get; set; }
+ public int MaxGasPPM { get; set; }
+
+ // User Activity
+ public int UserDailyReportsCount { get; set; }
+ public int ChecklistCompletionsCount { get; set; }
+ public int DailyAnalysesCount { get; set; }
+
+ // Performance Summary
+ public string PerformanceSummary { get; set; } = string.Empty;
+
+ public DateTime GeneratedAt { get; set; }
+}
+
diff --git a/src/GreenHome.Application/IUserDailyReportService.cs b/src/GreenHome.Application/IUserDailyReportService.cs
new file mode 100644
index 0000000..512a271
--- /dev/null
+++ b/src/GreenHome.Application/IUserDailyReportService.cs
@@ -0,0 +1,13 @@
+namespace GreenHome.Application;
+
+public interface IUserDailyReportService
+{
+ Task> GetReportsAsync(UserDailyReportFilter filter, CancellationToken cancellationToken);
+ Task GetReportByIdAsync(int id, CancellationToken cancellationToken);
+ Task CreateReportAsync(CreateUserDailyReportRequest request, CancellationToken cancellationToken);
+ Task UpdateReportAsync(UpdateUserDailyReportRequest request, CancellationToken cancellationToken);
+ Task DeleteReportAsync(int id, CancellationToken cancellationToken);
+ Task AddImageToReportAsync(int reportId, string fileName, string filePath, string contentType, long fileSize, string? description, CancellationToken cancellationToken);
+ Task DeleteImageAsync(int imageId, CancellationToken cancellationToken);
+}
+
diff --git a/src/GreenHome.Application/MappingProfile.cs b/src/GreenHome.Application/MappingProfile.cs
index 464900c..f4aabbf 100644
--- a/src/GreenHome.Application/MappingProfile.cs
+++ b/src/GreenHome.Application/MappingProfile.cs
@@ -32,5 +32,40 @@ public sealed class MappingProfile : Profile
CreateMap();
CreateMap().ReverseMap();
+
+ CreateMap()
+ .ForMember(dest => dest.DeviceName, opt => opt.MapFrom(src => src.Device.DeviceName))
+ .ForMember(dest => dest.UserName, opt => opt.MapFrom(src => src.User.Name))
+ .ForMember(dest => dest.UserMobile, opt => opt.MapFrom(src => src.User.Mobile))
+ .ReverseMap()
+ .ForMember(dest => dest.Device, opt => opt.Ignore())
+ .ForMember(dest => dest.User, opt => opt.Ignore())
+ .ForMember(dest => dest.AlertCondition, opt => opt.Ignore());
+
+ CreateMap()
+ .ForMember(dest => dest.DeviceName, opt => opt.MapFrom(src => src.Device.DeviceName))
+ .ForMember(dest => dest.UserName, opt => opt.MapFrom(src => src.User.Name))
+ .ForMember(dest => dest.UserFamily, opt => opt.MapFrom(src => src.User.Family));
+
+ CreateMap();
+
+ CreateMap()
+ .ForMember(dest => dest.DeviceName, opt => opt.MapFrom(src => src.Device.DeviceName))
+ .ForMember(dest => dest.CreatedByUserName, opt => opt.MapFrom(src => src.CreatedByUser.Name + " " + src.CreatedByUser.Family));
+
+ CreateMap();
+
+ CreateMap()
+ .ForMember(dest => dest.ChecklistTitle, opt => opt.MapFrom(src => src.Checklist.Title))
+ .ForMember(dest => dest.CompletedByUserName, opt => opt.MapFrom(src => src.CompletedByUser.Name + " " + src.CompletedByUser.Family));
+
+ CreateMap()
+ .ForMember(dest => dest.ItemTitle, opt => opt.MapFrom(src => src.ChecklistItem.Title));
+
+ CreateMap()
+ .ForMember(dest => dest.AuthorName, opt => opt.MapFrom(src => src.AuthorUser.Name))
+ .ForMember(dest => dest.AuthorFamily, opt => opt.MapFrom(src => src.AuthorUser.Family));
+
+ CreateMap();
}
}
diff --git a/src/GreenHome.Domain/AlertLog.cs b/src/GreenHome.Domain/AlertLog.cs
new file mode 100644
index 0000000..6f89a42
--- /dev/null
+++ b/src/GreenHome.Domain/AlertLog.cs
@@ -0,0 +1,110 @@
+namespace GreenHome.Domain;
+
+///
+/// لاگ هشدارهای ارسال شده
+///
+public sealed class AlertLog
+{
+ 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 int? AlertConditionId { get; set; }
+ public AlertCondition? AlertCondition { get; set; }
+
+ ///
+ /// نوع هشدار (SMS, Call, PowerOutage)
+ ///
+ public AlertType AlertType { get; set; }
+
+ ///
+ /// نوع اعلان (SMS یا Call)
+ ///
+ public AlertNotificationType NotificationType { get; set; }
+
+ ///
+ /// پیام هشدار
+ ///
+ public string Message { get; set; } = string.Empty;
+
+ ///
+ /// وضعیت ارسال
+ ///
+ public AlertStatus Status { get; set; }
+
+ ///
+ /// پیام خطا (در صورت شکست)
+ ///
+ public string? ErrorMessage { get; set; }
+
+ ///
+ /// شماره تماس یا پیامک
+ ///
+ public string PhoneNumber { get; set; } = string.Empty;
+
+ ///
+ /// زمان ارسال
+ ///
+ public DateTime SentAt { get; set; }
+
+ ///
+ /// مدت زمان پردازش (میلیثانیه)
+ ///
+ public long ProcessingTimeMs { get; set; }
+}
+
+///
+/// نوع هشدار
+///
+public enum AlertType
+{
+ ///
+ /// هشدار بر اساس شرط
+ ///
+ Condition = 1,
+
+ ///
+ /// هشدار قطع برق
+ ///
+ PowerOutage = 2,
+
+ ///
+ /// هشدار دستی
+ ///
+ Manual = 3
+}
+
+///
+/// وضعیت ارسال هشدار
+///
+public enum AlertStatus
+{
+ ///
+ /// با موفقیت ارسال شد
+ ///
+ Success = 1,
+
+ ///
+ /// با خطا مواجه شد
+ ///
+ Failed = 2,
+
+ ///
+ /// در صف ارسال
+ ///
+ Pending = 3
+}
+
diff --git a/src/GreenHome.Domain/AlertNotification.cs b/src/GreenHome.Domain/AlertNotification.cs
index ea9d948..de7cdb1 100644
--- a/src/GreenHome.Domain/AlertNotification.cs
+++ b/src/GreenHome.Domain/AlertNotification.cs
@@ -7,8 +7,8 @@ public sealed class AlertNotification
public Device Device { get; set; } = null!;
public int UserId { get; set; }
public User User { get; set; } = null!;
- public int AlertConditionId { get; set; }
- public AlertCondition AlertCondition { get; set; } = null!;
+ public int? AlertConditionId { get; set; }
+ public AlertCondition? AlertCondition { get; set; }
public AlertNotificationType NotificationType { get; set; } // Call or SMS
public string Message { get; set; } = string.Empty;
public string? MessageOutboxIds { get; set; } // JSON array of message outbox IDs (for SMS)
diff --git a/src/GreenHome.Domain/Checklist.cs b/src/GreenHome.Domain/Checklist.cs
new file mode 100644
index 0000000..275695b
--- /dev/null
+++ b/src/GreenHome.Domain/Checklist.cs
@@ -0,0 +1,156 @@
+namespace GreenHome.Domain;
+
+///
+/// چکلیست دستگاه
+///
+public sealed class Checklist
+{
+ public int Id { get; set; }
+
+ ///
+ /// شناسه دستگاه
+ ///
+ public int DeviceId { get; set; }
+ public Device Device { get; set; } = null!;
+
+ ///
+ /// عنوان چکلیست
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// توضیحات
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// آیا این چکلیست فعال است؟ (فقط یک چکلیست فعال برای هر دستگاه)
+ ///
+ public bool IsActive { get; set; } = true;
+
+ ///
+ /// آیتمهای چکلیست
+ ///
+ public ICollection Items { get; set; } = new List();
+
+ ///
+ /// سابقه تکمیلهای چکلیست
+ ///
+ public ICollection Completions { get; set; } = new List();
+
+ ///
+ /// زمان ایجاد
+ ///
+ public DateTime CreatedAt { get; set; }
+
+ ///
+ /// شناسه کاربر ایجاد کننده
+ ///
+ public int CreatedByUserId { get; set; }
+ public User CreatedByUser { get; set; } = null!;
+}
+
+///
+/// آیتم چکلیست
+///
+public sealed class ChecklistItem
+{
+ public int Id { get; set; }
+
+ ///
+ /// شناسه چکلیست
+ ///
+ public int ChecklistId { get; set; }
+ public Checklist Checklist { get; set; } = null!;
+
+ ///
+ /// عنوان آیتم
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// توضیحات
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// ترتیب نمایش
+ ///
+ public int Order { get; set; }
+
+ ///
+ /// آیا اجباری است؟
+ ///
+ public bool IsRequired { get; set; } = false;
+}
+
+///
+/// سابقه تکمیل چکلیست
+///
+public sealed class ChecklistCompletion
+{
+ public int Id { get; set; }
+
+ ///
+ /// شناسه چکلیست
+ ///
+ public int ChecklistId { get; set; }
+ public Checklist Checklist { get; set; } = null!;
+
+ ///
+ /// شناسه کاربر انجام دهنده
+ ///
+ public int CompletedByUserId { get; set; }
+ public User CompletedByUser { get; set; } = null!;
+
+ ///
+ /// تاریخ شمسی تکمیل
+ ///
+ public string PersianDate { get; set; } = string.Empty;
+
+ ///
+ /// آیتمهای چک شده
+ ///
+ public ICollection ItemCompletions { get; set; } = new List();
+
+ ///
+ /// یادداشتهای اضافی
+ ///
+ public string? Notes { get; set; }
+
+ ///
+ /// زمان تکمیل
+ ///
+ public DateTime CompletedAt { get; set; }
+}
+
+///
+/// تکمیل آیتم چکلیست
+///
+public sealed class ChecklistItemCompletion
+{
+ public int Id { get; set; }
+
+ ///
+ /// شناسه تکمیل چکلیست
+ ///
+ public int ChecklistCompletionId { get; set; }
+ public ChecklistCompletion ChecklistCompletion { get; set; } = null!;
+
+ ///
+ /// شناسه آیتم چکلیست
+ ///
+ public int ChecklistItemId { get; set; }
+ public ChecklistItem ChecklistItem { get; set; } = null!;
+
+ ///
+ /// آیا چک شده؟
+ ///
+ public bool IsChecked { get; set; }
+
+ ///
+ /// یادداشت برای این آیتم
+ ///
+ public string? Note { get; set; }
+}
+
diff --git a/src/GreenHome.Domain/DevicePost.cs b/src/GreenHome.Domain/DevicePost.cs
new file mode 100644
index 0000000..535b025
--- /dev/null
+++ b/src/GreenHome.Domain/DevicePost.cs
@@ -0,0 +1,81 @@
+namespace GreenHome.Domain;
+
+///
+/// پستهای گروه مجازی دستگاه (تایملاین مشترک)
+///
+public sealed class DevicePost
+{
+ public int Id { get; set; }
+
+ ///
+ /// شناسه دستگاه
+ ///
+ public int DeviceId { get; set; }
+ public Device Device { get; set; } = null!;
+
+ ///
+ /// شناسه کاربر نویسنده پست
+ ///
+ public int AuthorUserId { get; set; }
+ public User AuthorUser { get; set; } = null!;
+
+ ///
+ /// متن پست
+ ///
+ public string Content { get; set; } = string.Empty;
+
+ ///
+ /// تصاویر پیوست
+ ///
+ public ICollection Images { get; set; } = new List();
+
+ ///
+ /// زمان ایجاد
+ ///
+ public DateTime CreatedAt { get; set; }
+
+ ///
+ /// زمان آخرین ویرایش
+ ///
+ public DateTime? UpdatedAt { get; set; }
+}
+
+///
+/// تصاویر پیوست پست
+///
+public sealed class DevicePostImage
+{
+ public int Id { get; set; }
+
+ ///
+ /// شناسه پست
+ ///
+ public int DevicePostId { get; set; }
+ public DevicePost DevicePost { get; set; } = null!;
+
+ ///
+ /// نام فایل
+ ///
+ public string FileName { get; set; } = string.Empty;
+
+ ///
+ /// مسیر ذخیره فایل
+ ///
+ public string FilePath { get; set; } = string.Empty;
+
+ ///
+ /// نوع فایل (MIME type)
+ ///
+ public string ContentType { get; set; } = string.Empty;
+
+ ///
+ /// حجم فایل (بایت)
+ ///
+ public long FileSize { get; set; }
+
+ ///
+ /// زمان آپلود
+ ///
+ public DateTime UploadedAt { get; set; }
+}
+
diff --git a/src/GreenHome.Domain/DeviceSettings.cs b/src/GreenHome.Domain/DeviceSettings.cs
index a93199a..cb94240 100644
--- a/src/GreenHome.Domain/DeviceSettings.cs
+++ b/src/GreenHome.Domain/DeviceSettings.cs
@@ -26,6 +26,26 @@ public sealed class DeviceSettings
///
public decimal? Longitude { get; set; }
+ ///
+ /// نوع محصول
+ ///
+ public string ProductType { get; set; } = string.Empty;
+
+ ///
+ /// حداقل فاصله زمانی ارسال پیامک (به دقیقه)
+ ///
+ public int MinimumSmsIntervalMinutes { get; set; } = 15;
+
+ ///
+ /// حداقل فاصله زمانی تماس (به دقیقه)
+ ///
+ public int MinimumCallIntervalMinutes { get; set; } = 60;
+
+ ///
+ /// مساحت گلخانه (متر مربع)
+ ///
+ public decimal? AreaSquareMeters { get; set; }
+
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
\ No newline at end of file
diff --git a/src/GreenHome.Domain/DeviceUser.cs b/src/GreenHome.Domain/DeviceUser.cs
index 06fb4c1..22f34f0 100644
--- a/src/GreenHome.Domain/DeviceUser.cs
+++ b/src/GreenHome.Domain/DeviceUser.cs
@@ -6,5 +6,10 @@ public sealed class DeviceUser
public Device Device { get; set; } = null!;
public int UserId { get; set; }
public User User { get; set; } = null!;
+
+ ///
+ /// آیا این کاربر باید هشدارهای این دستگاه را دریافت کند؟
+ ///
+ public bool ReceiveAlerts { get; set; } = true;
}
diff --git a/src/GreenHome.Domain/UserDailyReport.cs b/src/GreenHome.Domain/UserDailyReport.cs
new file mode 100644
index 0000000..2b24a58
--- /dev/null
+++ b/src/GreenHome.Domain/UserDailyReport.cs
@@ -0,0 +1,121 @@
+namespace GreenHome.Domain;
+
+///
+/// گزارش روزانه کاربر (مشاهدات و عملیات انجام شده)
+///
+public sealed class UserDailyReport
+{
+ 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!;
+
+ ///
+ /// تاریخ شمسی (yyyy/MM/dd)
+ ///
+ public string PersianDate { get; set; } = string.Empty;
+
+ ///
+ /// سال شمسی
+ ///
+ public int PersianYear { get; set; }
+
+ ///
+ /// ماه شمسی
+ ///
+ public int PersianMonth { get; set; }
+
+ ///
+ /// روز شمسی
+ ///
+ public int PersianDay { get; set; }
+
+ ///
+ /// عنوان گزارش
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// شرح مشاهدات
+ ///
+ public string Observations { get; set; } = string.Empty;
+
+ ///
+ /// عملیات انجام شده
+ ///
+ public string Operations { get; set; } = string.Empty;
+
+ ///
+ /// یادداشتهای اضافی
+ ///
+ public string? Notes { get; set; }
+
+ ///
+ /// تصاویر پیوست
+ ///
+ public ICollection Images { get; set; } = new List();
+
+ ///
+ /// زمان ایجاد
+ ///
+ public DateTime CreatedAt { get; set; }
+
+ ///
+ /// زمان آخرین ویرایش
+ ///
+ public DateTime UpdatedAt { get; set; }
+}
+
+///
+/// تصاویر پیوست گزارش روزانه
+///
+public sealed class ReportImage
+{
+ public int Id { get; set; }
+
+ ///
+ /// شناسه گزارش
+ ///
+ public int UserDailyReportId { get; set; }
+ public UserDailyReport UserDailyReport { get; set; } = null!;
+
+ ///
+ /// نام فایل
+ ///
+ public string FileName { get; set; } = string.Empty;
+
+ ///
+ /// مسیر ذخیره فایل
+ ///
+ public string FilePath { get; set; } = string.Empty;
+
+ ///
+ /// نوع فایل (MIME type)
+ ///
+ public string ContentType { get; set; } = string.Empty;
+
+ ///
+ /// حجم فایل (بایت)
+ ///
+ public long FileSize { get; set; }
+
+ ///
+ /// توضیحات تصویر
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// زمان آپلود
+ ///
+ public DateTime UploadedAt { get; set; }
+}
+
diff --git a/src/GreenHome.Infrastructure/AlertLogService.cs b/src/GreenHome.Infrastructure/AlertLogService.cs
new file mode 100644
index 0000000..2699de1
--- /dev/null
+++ b/src/GreenHome.Infrastructure/AlertLogService.cs
@@ -0,0 +1,100 @@
+using AutoMapper;
+using GreenHome.Application;
+using Microsoft.EntityFrameworkCore;
+
+namespace GreenHome.Infrastructure;
+
+public sealed class AlertLogService : IAlertLogService
+{
+ private readonly GreenHomeDbContext _context;
+ private readonly IMapper _mapper;
+
+ public AlertLogService(GreenHomeDbContext context, IMapper mapper)
+ {
+ _context = context;
+ _mapper = mapper;
+ }
+
+ public async Task> GetAlertLogsAsync(
+ AlertLogFilter filter,
+ CancellationToken cancellationToken)
+ {
+ var query = _context.AlertLogs
+ .Include(x => x.Device)
+ .Include(x => x.User)
+ .AsNoTracking()
+ .AsQueryable();
+
+ // Apply filters
+ if (filter.DeviceId.HasValue)
+ {
+ query = query.Where(x => x.DeviceId == filter.DeviceId.Value);
+ }
+
+ if (filter.UserId.HasValue)
+ {
+ query = query.Where(x => x.UserId == filter.UserId.Value);
+ }
+
+ if (filter.AlertType.HasValue)
+ {
+ query = query.Where(x => x.AlertType == filter.AlertType.Value);
+ }
+
+ if (filter.Status.HasValue)
+ {
+ query = query.Where(x => x.Status == filter.Status.Value);
+ }
+
+ if (filter.StartDate.HasValue)
+ {
+ query = query.Where(x => x.SentAt >= filter.StartDate.Value);
+ }
+
+ if (filter.EndDate.HasValue)
+ {
+ query = query.Where(x => x.SentAt <= filter.EndDate.Value);
+ }
+
+ // Get total count
+ var totalCount = await query.CountAsync(cancellationToken);
+
+ // Apply pagination and ordering
+ var items = await query
+ .OrderByDescending(x => x.SentAt)
+ .Skip((filter.Page - 1) * filter.PageSize)
+ .Take(filter.PageSize)
+ .ToListAsync(cancellationToken);
+
+ var dtos = _mapper.Map>(items);
+
+ return new PagedResult
+ {
+ Items = dtos,
+ TotalCount = totalCount,
+ Page = filter.Page,
+ PageSize = filter.PageSize
+ };
+ }
+
+ public async Task GetAlertLogByIdAsync(int id, CancellationToken cancellationToken)
+ {
+ var log = await _context.AlertLogs
+ .Include(x => x.Device)
+ .Include(x => x.User)
+ .Include(x => x.AlertCondition)
+ .AsNoTracking()
+ .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
+
+ return log != null ? _mapper.Map(log) : null;
+ }
+
+ public async Task CreateAlertLogAsync(AlertLogDto dto, CancellationToken cancellationToken)
+ {
+ var entity = _mapper.Map(dto);
+ _context.AlertLogs.Add(entity);
+ await _context.SaveChangesAsync(cancellationToken);
+ return entity.Id;
+ }
+}
+
diff --git a/src/GreenHome.Infrastructure/AlertService.cs b/src/GreenHome.Infrastructure/AlertService.cs
index c2cb752..922a103 100644
--- a/src/GreenHome.Infrastructure/AlertService.cs
+++ b/src/GreenHome.Infrastructure/AlertService.cs
@@ -35,14 +35,28 @@ public sealed class AlertService : IAlertService
public async Task CheckAndSendAlertsAsync(int deviceId, TelemetryDto telemetry, CancellationToken cancellationToken)
{
- // Get device with settings and user
+ // Get device with all users who should receive alerts
var device = await dbContext.Devices
.Include(d => d.User)
+ .Include(d => d.DeviceUsers.Where(du => du.ReceiveAlerts))
+ .ThenInclude(du => du.User)
.FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken);
- if (device == null || device.User == null)
+ if (device == null)
{
- logger.LogWarning("Device or user not found: DeviceId={DeviceId}", deviceId);
+ logger.LogWarning("Device not found: DeviceId={DeviceId}", deviceId);
+ return;
+ }
+
+ // Get all users who should receive alerts
+ var usersToAlert = device.DeviceUsers
+ .Where(du => du.ReceiveAlerts)
+ .Select(du => du.User)
+ .ToList();
+
+ if (usersToAlert.Count == 0)
+ {
+ logger.LogInformation("No users with ReceiveAlerts enabled for device: DeviceId={DeviceId}", deviceId);
return;
}
@@ -87,7 +101,7 @@ public sealed class AlertService : IAlertService
if (allRulesMatch && condition.Rules.Any())
{
// All rules passed, send alert if cooldown period has passed
- await SendAlertForConditionAsync(condition, device, telemetry, cancellationToken);
+ await SendAlertForConditionAsync(condition, device, usersToAlert, telemetry, cancellationToken);
}
}
}
@@ -119,6 +133,7 @@ public sealed class AlertService : IAlertService
private async Task SendAlertForConditionAsync(
Domain.AlertCondition condition,
Domain.Device device,
+ List usersToAlert,
TelemetryDto telemetry,
CancellationToken cancellationToken)
{
@@ -127,68 +142,95 @@ public sealed class AlertService : IAlertService
? condition.CallCooldownMinutes
: condition.SmsCooldownMinutes;
- // Check if alert was sent recently
- var cooldownTime = DateTime.UtcNow.AddMinutes(-cooldownMinutes);
- var recentAlert = await dbContext.AlertNotifications
- .Where(a => a.DeviceId == device.Id &&
- a.UserId == device.User.Id &&
- a.AlertConditionId == condition.Id &&
- a.SentAt >= cooldownTime)
- .FirstOrDefaultAsync(cancellationToken);
-
- if (recentAlert != null)
- {
- logger.LogInformation("Alert skipped due to cooldown: DeviceId={DeviceId}, ConditionId={ConditionId}, Type={Type}",
- device.Id, condition.Id, condition.NotificationType);
- return;
- }
-
- // Build alert message
+ // Build alert message once
var message = BuildAlertMessage(condition, device.DeviceName, telemetry);
+ var sentAt = DateTime.UtcNow;
- // Send notification
- string? messageOutboxIds = null;
- string? errorMessage = null;
- bool isSent = false;
+ // Send alert to each user
+ foreach (var user in usersToAlert)
+ {
+ // Check if alert was sent recently to this user
+ var cooldownTime = sentAt.AddMinutes(-cooldownMinutes);
+ var recentAlert = await dbContext.AlertNotifications
+ .Where(a => a.DeviceId == device.Id &&
+ a.UserId == user.Id &&
+ a.AlertConditionId == condition.Id &&
+ a.SentAt >= cooldownTime)
+ .FirstOrDefaultAsync(cancellationToken);
- try
- {
- if (condition.NotificationType == Domain.AlertNotificationType.SMS)
+ if (recentAlert != null)
{
- (isSent, messageOutboxIds, errorMessage) = await SendSmsAlertAsync(device.User.Mobile, device.DeviceName, message, cancellationToken);
+ logger.LogInformation("Alert skipped due to cooldown: DeviceId={DeviceId}, UserId={UserId}, ConditionId={ConditionId}",
+ device.Id, user.Id, condition.Id);
+ continue;
}
- else // Call
+
+ // Send notification
+ var startTime = DateTime.UtcNow;
+ string? messageOutboxIds = null;
+ string? errorMessage = null;
+ bool isSent = false;
+
+ try
{
- (isSent, messageOutboxIds, errorMessage) = await SendCallAlertAsync(device.User.Mobile, device.DeviceName, message, cancellationToken);
+ if (condition.NotificationType == Domain.AlertNotificationType.SMS)
+ {
+ (isSent, messageOutboxIds, errorMessage) = await SendSmsAlertAsync(user.Mobile, device.DeviceName, message, cancellationToken);
+ }
+ else // Call
+ {
+ (isSent, messageOutboxIds, errorMessage) = await SendCallAlertAsync(user.Mobile, device.DeviceName, message, cancellationToken);
+ }
}
- }
- catch (Exception ex)
- {
- errorMessage = $"Exception: {ex.Message}";
- if (ex.InnerException != null)
+ catch (Exception ex)
{
- errorMessage += $" | InnerException: {ex.InnerException.Message}";
+ errorMessage = $"Exception: {ex.Message}";
+ if (ex.InnerException != null)
+ {
+ errorMessage += $" | InnerException: {ex.InnerException.Message}";
+ }
+ isSent = false;
+ logger.LogError(ex, "Failed to send alert: DeviceId={DeviceId}, UserId={UserId}, ConditionId={ConditionId}",
+ device.Id, user.Id, condition.Id);
}
- isSent = false;
- logger.LogError(ex, "Failed to send alert: DeviceId={DeviceId}, ConditionId={ConditionId}, Type={Type}",
- device.Id, condition.Id, condition.NotificationType);
+
+ var processingTime = (long)(DateTime.UtcNow - startTime).TotalMilliseconds;
+
+ // Save notification to database (old table for backwards compatibility)
+ var notification = new Domain.AlertNotification
+ {
+ DeviceId = device.Id,
+ UserId = user.Id,
+ AlertConditionId = condition.Id,
+ NotificationType = condition.NotificationType,
+ Message = message,
+ MessageOutboxIds = messageOutboxIds,
+ ErrorMessage = errorMessage,
+ SentAt = sentAt,
+ IsSent = isSent
+ };
+
+ dbContext.AlertNotifications.Add(notification);
+
+ // Log the alert
+ var alertLog = new Domain.AlertLog
+ {
+ DeviceId = device.Id,
+ UserId = user.Id,
+ AlertConditionId = condition.Id,
+ AlertType = Domain.AlertType.Condition,
+ NotificationType = condition.NotificationType,
+ Message = message,
+ Status = isSent ? Domain.AlertStatus.Success : Domain.AlertStatus.Failed,
+ ErrorMessage = errorMessage,
+ PhoneNumber = user.Mobile,
+ SentAt = sentAt,
+ ProcessingTimeMs = processingTime
+ };
+
+ dbContext.AlertLogs.Add(alertLog);
}
- // Save notification to database
- var notification = new Domain.AlertNotification
- {
- DeviceId = device.Id,
- UserId = device.User.Id,
- AlertConditionId = condition.Id,
- NotificationType = condition.NotificationType,
- Message = message,
- MessageOutboxIds = messageOutboxIds,
- ErrorMessage = errorMessage,
- SentAt = DateTime.UtcNow,
- IsSent = isSent
- };
-
- dbContext.AlertNotifications.Add(notification);
await dbContext.SaveChangesAsync(cancellationToken);
}
@@ -323,5 +365,131 @@ public sealed class AlertService : IAlertService
return (false, null, errorMsg);
}
}
+
+ public async Task SendPowerOutageAlertAsync(int deviceId, CancellationToken cancellationToken)
+ {
+ // Get device with all users who should receive alerts
+ var device = await dbContext.Devices
+ .Include(d => d.DeviceUsers.Where(du => du.ReceiveAlerts))
+ .ThenInclude(du => du.User)
+ .FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken);
+
+ if (device == null)
+ {
+ logger.LogWarning("Device not found for power outage alert: DeviceId={DeviceId}", deviceId);
+ throw new InvalidOperationException($"دستگاه با شناسه {deviceId} یافت نشد");
+ }
+
+ // Get all users who should receive alerts
+ var usersToAlert = device.DeviceUsers
+ .Where(du => du.ReceiveAlerts)
+ .Select(du => du.User)
+ .ToList();
+
+ if (usersToAlert.Count == 0)
+ {
+ logger.LogInformation("No users with ReceiveAlerts enabled for power outage: DeviceId={DeviceId}", deviceId);
+ return;
+ }
+
+ var message = $"⚠️ هشدار قطع برق! دستگاه {device.DeviceName} از برق قطع شده است.";
+ var sentAt = DateTime.UtcNow;
+
+ // Send to all users (both SMS and Call for power outage - it's critical!)
+ foreach (var user in usersToAlert)
+ {
+ // Send SMS
+ await SendPowerOutageNotificationAsync(
+ device, user, message, sentAt,
+ Domain.AlertNotificationType.SMS,
+ cancellationToken);
+
+ // Send Call (important alert)
+ await SendPowerOutageNotificationAsync(
+ device, user, message, sentAt,
+ Domain.AlertNotificationType.Call,
+ cancellationToken);
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ logger.LogInformation("Power outage alerts sent to {Count} users for device {DeviceId}",
+ usersToAlert.Count, deviceId);
+ }
+
+ private async Task SendPowerOutageNotificationAsync(
+ Domain.Device device,
+ Domain.User user,
+ string message,
+ DateTime sentAt,
+ Domain.AlertNotificationType notificationType,
+ CancellationToken cancellationToken)
+ {
+ var startTime = DateTime.UtcNow;
+ string? messageOutboxIds = null;
+ string? errorMessage = null;
+ bool isSent = false;
+
+ try
+ {
+ if (notificationType == Domain.AlertNotificationType.SMS)
+ {
+ (isSent, messageOutboxIds, errorMessage) = await SendSmsAlertAsync(
+ user.Mobile, device.DeviceName, message, cancellationToken);
+ }
+ else
+ {
+ (isSent, messageOutboxIds, errorMessage) = await SendCallAlertAsync(
+ user.Mobile, device.DeviceName, message, cancellationToken);
+ }
+ }
+ catch (Exception ex)
+ {
+ errorMessage = $"Exception: {ex.Message}";
+ if (ex.InnerException != null)
+ {
+ errorMessage += $" | InnerException: {ex.InnerException.Message}";
+ }
+ isSent = false;
+ logger.LogError(ex, "Failed to send power outage alert: DeviceId={DeviceId}, UserId={UserId}, Type={Type}",
+ device.Id, user.Id, notificationType);
+ }
+
+ var processingTime = (long)(DateTime.UtcNow - startTime).TotalMilliseconds;
+
+ // Save notification (old table)
+ var notification = new Domain.AlertNotification
+ {
+ DeviceId = device.Id,
+ UserId = user.Id,
+ AlertConditionId = null,
+ NotificationType = notificationType,
+ Message = message,
+ MessageOutboxIds = messageOutboxIds,
+ ErrorMessage = errorMessage,
+ SentAt = sentAt,
+ IsSent = isSent
+ };
+
+ dbContext.AlertNotifications.Add(notification);
+
+ // Log the alert
+ var alertLog = new Domain.AlertLog
+ {
+ DeviceId = device.Id,
+ UserId = user.Id,
+ AlertConditionId = null,
+ AlertType = Domain.AlertType.PowerOutage,
+ NotificationType = notificationType,
+ Message = message,
+ Status = isSent ? Domain.AlertStatus.Success : Domain.AlertStatus.Failed,
+ ErrorMessage = errorMessage,
+ PhoneNumber = user.Mobile,
+ SentAt = sentAt,
+ ProcessingTimeMs = processingTime
+ };
+
+ dbContext.AlertLogs.Add(alertLog);
+ }
}
diff --git a/src/GreenHome.Infrastructure/ChecklistService.cs b/src/GreenHome.Infrastructure/ChecklistService.cs
new file mode 100644
index 0000000..a15f45e
--- /dev/null
+++ b/src/GreenHome.Infrastructure/ChecklistService.cs
@@ -0,0 +1,152 @@
+using AutoMapper;
+using GreenHome.Application;
+using Microsoft.EntityFrameworkCore;
+
+namespace GreenHome.Infrastructure;
+
+public sealed class ChecklistService : IChecklistService
+{
+ private readonly GreenHomeDbContext _context;
+ private readonly IMapper _mapper;
+
+ public ChecklistService(GreenHomeDbContext context, IMapper mapper)
+ {
+ _context = context;
+ _mapper = mapper;
+ }
+
+ public async Task GetActiveChecklistByDeviceIdAsync(int deviceId, CancellationToken cancellationToken)
+ {
+ var checklist = await _context.Checklists
+ .Include(c => c.Device)
+ .Include(c => c.CreatedByUser)
+ .Include(c => c.Items.OrderBy(i => i.Order))
+ .AsNoTracking()
+ .FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.IsActive, cancellationToken);
+
+ return checklist != null ? _mapper.Map(checklist) : null;
+ }
+
+ public async Task> GetChecklistsByDeviceIdAsync(int deviceId, CancellationToken cancellationToken)
+ {
+ var checklists = await _context.Checklists
+ .Include(c => c.Device)
+ .Include(c => c.CreatedByUser)
+ .Include(c => c.Items.OrderBy(i => i.Order))
+ .AsNoTracking()
+ .Where(c => c.DeviceId == deviceId)
+ .OrderByDescending(c => c.IsActive)
+ .ThenByDescending(c => c.CreatedAt)
+ .ToListAsync(cancellationToken);
+
+ return _mapper.Map>(checklists);
+ }
+
+ public async Task GetChecklistByIdAsync(int id, CancellationToken cancellationToken)
+ {
+ var checklist = await _context.Checklists
+ .Include(c => c.Device)
+ .Include(c => c.CreatedByUser)
+ .Include(c => c.Items.OrderBy(i => i.Order))
+ .AsNoTracking()
+ .FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
+
+ return checklist != null ? _mapper.Map(checklist) : null;
+ }
+
+ public async Task CreateChecklistAsync(CreateChecklistRequest request, CancellationToken cancellationToken)
+ {
+ // Deactivate existing active checklist for this device
+ var existingActiveChecklist = await _context.Checklists
+ .FirstOrDefaultAsync(c => c.DeviceId == request.DeviceId && c.IsActive, cancellationToken);
+
+ if (existingActiveChecklist != null)
+ {
+ existingActiveChecklist.IsActive = false;
+ }
+
+ // Create new checklist
+ var checklist = new Domain.Checklist
+ {
+ DeviceId = request.DeviceId,
+ CreatedByUserId = request.CreatedByUserId,
+ Title = request.Title,
+ Description = request.Description,
+ IsActive = true,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ // Add items
+ foreach (var itemRequest in request.Items)
+ {
+ var item = new Domain.ChecklistItem
+ {
+ Title = itemRequest.Title,
+ Description = itemRequest.Description,
+ Order = itemRequest.Order,
+ IsRequired = itemRequest.IsRequired
+ };
+ checklist.Items.Add(item);
+ }
+
+ _context.Checklists.Add(checklist);
+ await _context.SaveChangesAsync(cancellationToken);
+
+ return checklist.Id;
+ }
+
+ public async Task> GetCompletionsByChecklistIdAsync(
+ int checklistId,
+ CancellationToken cancellationToken)
+ {
+ var completions = await _context.ChecklistCompletions
+ .Include(cc => cc.Checklist)
+ .Include(cc => cc.CompletedByUser)
+ .Include(cc => cc.ItemCompletions)
+ .ThenInclude(ic => ic.ChecklistItem)
+ .AsNoTracking()
+ .Where(cc => cc.ChecklistId == checklistId)
+ .OrderByDescending(cc => cc.CompletedAt)
+ .ToListAsync(cancellationToken);
+
+ return _mapper.Map>(completions);
+ }
+
+ public async Task CompleteChecklistAsync(CompleteChecklistRequest request, CancellationToken cancellationToken)
+ {
+ var checklist = await _context.Checklists
+ .Include(c => c.Items)
+ .FirstOrDefaultAsync(c => c.Id == request.ChecklistId, cancellationToken);
+
+ if (checklist == null)
+ {
+ throw new InvalidOperationException($"چکلیست با شناسه {request.ChecklistId} یافت نشد");
+ }
+
+ var completion = new Domain.ChecklistCompletion
+ {
+ ChecklistId = request.ChecklistId,
+ CompletedByUserId = request.CompletedByUserId,
+ PersianDate = request.PersianDate,
+ Notes = request.Notes,
+ CompletedAt = DateTime.UtcNow
+ };
+
+ foreach (var itemCompletion in request.ItemCompletions)
+ {
+ var item = new Domain.ChecklistItemCompletion
+ {
+ ChecklistItemId = itemCompletion.ChecklistItemId,
+ IsChecked = itemCompletion.IsChecked,
+ Note = itemCompletion.Note
+ };
+ completion.ItemCompletions.Add(item);
+ }
+
+ _context.ChecklistCompletions.Add(completion);
+ await _context.SaveChangesAsync(cancellationToken);
+
+ return completion.Id;
+ }
+}
+
diff --git a/src/GreenHome.Infrastructure/DailyReportService.cs b/src/GreenHome.Infrastructure/DailyReportService.cs
index ddd113f..b6154d0 100644
--- a/src/GreenHome.Infrastructure/DailyReportService.cs
+++ b/src/GreenHome.Infrastructure/DailyReportService.cs
@@ -70,6 +70,10 @@ public class DailyReportService : IDailyReportService
throw new InvalidOperationException($"دستگاه با شناسه {request.DeviceId} یافت نشد");
}
+ // Get device settings (including ProductType if available)
+ var deviceSettings = await _context.DeviceSettings
+ .FirstOrDefaultAsync(ds => ds.DeviceId == request.DeviceId, cancellationToken);
+
// Query telemetry data for the specified date
var telemetryRecords = await _context.TelemetryRecords
.Where(t => t.DeviceId == request.DeviceId && t.PersianDate == request.PersianDate)
@@ -115,7 +119,15 @@ public class DailyReportService : IDailyReportService
}
// Prepare the question for AI
- var question = $@"این دادههای تلمتری یک روز ({request.PersianDate}) از یک گلخانه هوشمند هستند:
+ var productTypeInfo = !string.IsNullOrWhiteSpace(deviceSettings?.ProductType)
+ ? $" محصول کشت شده: {deviceSettings.ProductType}."
+ : string.Empty;
+
+ var areaInfo = deviceSettings?.AreaSquareMeters != null
+ ? $" مساحت گلخانه: {deviceSettings.AreaSquareMeters:F1} متر مربع."
+ : string.Empty;
+
+ var question = $@"این دادههای تلمتری یک روز ({request.PersianDate}) از یک گلخانه هوشمند هستند.{productTypeInfo}{areaInfo}
{dataBuilder}
@@ -123,7 +135,7 @@ public class DailyReportService : IDailyReportService
1. وضعیت کلی دما، رطوبت، نور و کیفیت هوا
2. روندهای مشاهده شده در طول روز
3. هر گونه نکته یا هشدار مهم
-4. پیشنهادات برای بهبود شرایط گلخانه
+4. پیشنهادات برای بهبود شرایط گلخانه{(productTypeInfo != string.Empty ? " و رشد بهتر محصول" : string.Empty)}
خلاصه و مفید باش (حداکثر 300 کلمه).";
@@ -133,12 +145,16 @@ public class DailyReportService : IDailyReportService
try
{
+ var systemMessage = !string.IsNullOrWhiteSpace(deviceSettings?.ProductType)
+ ? $"تو یک متخصص کشاورزی و گلخانه هستی که در کشت {deviceSettings.ProductType} تخصص داری و دادههای تلمتری رو تحلیل میکنی."
+ : "تو یک متخصص کشاورزی و گلخانه هستی که دادههای تلمتری رو تحلیل میکنی.";
+
var chatRequest = new ChatRequest
{
Model = "deepseek-chat",
Messages = new List
{
- new() { Role = "system", Content = "تو یک متخصص کشاورزی و گلخانه هستی که دادههای تلمتری رو تحلیل میکنی." },
+ new() { Role = "system", Content = systemMessage },
new() { Role = "user", Content = question }
},
Temperature = 0.7
@@ -225,5 +241,239 @@ public class DailyReportService : IDailyReportService
return true;
}
+
+ public async Task GetWeeklyAnalysisAsync(
+ WeeklyAnalysisRequest request,
+ CancellationToken cancellationToken)
+ {
+ // Get device info
+ var device = await _context.Devices
+ .FirstOrDefaultAsync(d => d.Id == request.DeviceId, cancellationToken);
+
+ if (device == null)
+ {
+ throw new InvalidOperationException($"دستگاه با شناسه {request.DeviceId} یافت نشد");
+ }
+
+ // Get device settings
+ var deviceSettings = await _context.DeviceSettings
+ .FirstOrDefaultAsync(ds => ds.DeviceId == request.DeviceId, cancellationToken);
+
+ // Query telemetry data for the week
+ var telemetryRecords = await _context.TelemetryRecords
+ .Where(t => t.DeviceId == request.DeviceId &&
+ string.Compare(t.PersianDate, request.StartDate) >= 0 &&
+ string.Compare(t.PersianDate, request.EndDate) <= 0)
+ .OrderBy(t => t.TimestampUtc)
+ .Select(t => new
+ {
+ t.TimestampUtc,
+ t.TemperatureC,
+ t.HumidityPercent,
+ t.Lux,
+ t.GasPPM,
+ t.PersianDate
+ })
+ .ToListAsync(cancellationToken);
+
+ if (telemetryRecords.Count == 0)
+ {
+ throw new InvalidOperationException(
+ $"هیچ رکوردی برای دستگاه {request.DeviceId} در بازه {request.StartDate} تا {request.EndDate} یافت نشد");
+ }
+
+ // Sample 1 per 100 records
+ var sampledRecords = telemetryRecords
+ .Select((record, index) => new { record, index })
+ .Where(x => x.index % 100 == 0)
+ .Select(x => x.record)
+ .ToList();
+
+ _logger.LogInformation(
+ "تعداد {TotalCount} رکورد یافت شد. نمونهبرداری هفتگی: {SampledCount} رکورد",
+ telemetryRecords.Count, sampledRecords.Count);
+
+ // Build the data string
+ var dataBuilder = new StringBuilder();
+ dataBuilder.AppendLine("تاریخ | زمان | دما (°C) | رطوبت (%) | نور (Lux) | CO (PPM)");
+ dataBuilder.AppendLine("---------|----------|----------|-----------|-----------|----------");
+
+ foreach (var record in sampledRecords)
+ {
+ var localTime = record.TimestampUtc.AddHours(3.5);
+ dataBuilder.AppendLine(
+ $"{record.PersianDate} | {localTime:HH:mm} | {record.TemperatureC:F1} | {record.HumidityPercent:F1} | {record.Lux:F1} | {record.GasPPM}");
+ }
+
+ // Prepare AI prompt
+ var productTypeInfo = !string.IsNullOrWhiteSpace(deviceSettings?.ProductType)
+ ? $" محصول کشت شده: {deviceSettings.ProductType}."
+ : string.Empty;
+
+ var areaInfo = deviceSettings?.AreaSquareMeters != null
+ ? $" مساحت گلخانه: {deviceSettings.AreaSquareMeters:F1} متر مربع."
+ : string.Empty;
+
+ var question = $@"این دادههای تلمتری یک هفته ({request.StartDate} تا {request.EndDate}) از یک گلخانه هوشمند هستند.{productTypeInfo}{areaInfo}
+
+{dataBuilder}
+
+لطفاً یک تحلیل جامع هفتگی بده که شامل:
+1. خلاصه روند هفتگی دما، رطوبت، نور و کیفیت هوا
+2. مقایسه شرایط در روزهای مختلف هفته
+3. نکات و هشدارهای مهم
+4. توصیهها برای هفته آینده
+
+خلاصه و کاربردی باش (حداکثر 500 کلمه).";
+
+ var systemMessage = !string.IsNullOrWhiteSpace(deviceSettings?.ProductType)
+ ? $"تو یک متخصص کشاورزی و گلخانه هستی که در کشت {deviceSettings.ProductType} تخصص داری و دادههای تلمتری رو تحلیل میکنی."
+ : "تو یک متخصص کشاورزی و گلخانه هستی که دادههای تلمتری رو تحلیل میکنی.";
+
+ // Send to DeepSeek
+ var stopwatch = Stopwatch.StartNew();
+ var chatRequest = new ChatRequest
+ {
+ Model = "deepseek-chat",
+ Messages = new List
+ {
+ new() { Role = "system", Content = systemMessage },
+ new() { Role = "user", Content = question }
+ },
+ Temperature = 0.7
+ };
+
+ var aiResponse = await _deepSeekService.AskAsync(chatRequest, cancellationToken);
+ stopwatch.Stop();
+
+ if (aiResponse?.Choices == null || aiResponse.Choices.Count == 0 ||
+ string.IsNullOrWhiteSpace(aiResponse.Choices[0].Message?.Content))
+ {
+ throw new InvalidOperationException("پاسخ نامعتبر از سرویس هوش مصنوعی");
+ }
+
+ return new DailyReportResponse
+ {
+ Id = 0,
+ DeviceId = request.DeviceId,
+ DeviceName = device.DeviceName,
+ PersianDate = $"{request.StartDate} تا {request.EndDate}",
+ Analysis = aiResponse.Choices[0].Message!.Content,
+ RecordCount = telemetryRecords.Count,
+ SampledRecordCount = sampledRecords.Count,
+ TotalTokens = aiResponse.Usage?.TotalTokens ?? 0,
+ CreatedAt = DateTime.UtcNow,
+ FromCache = false
+ };
+ }
+
+ public async Task GetMonthlyAnalysisAsync(
+ MonthlyAnalysisRequest request,
+ CancellationToken cancellationToken)
+ {
+ // Get device info
+ var device = await _context.Devices
+ .FirstOrDefaultAsync(d => d.Id == request.DeviceId, cancellationToken);
+
+ if (device == null)
+ {
+ throw new InvalidOperationException($"دستگاه با شناسه {request.DeviceId} یافت نشد");
+ }
+
+ // Get device settings
+ var deviceSettings = await _context.DeviceSettings
+ .FirstOrDefaultAsync(ds => ds.DeviceId == request.DeviceId, cancellationToken);
+
+ // Get all daily reports for this month
+ var dailyReports = await _context.DailyReports
+ .Where(dr => dr.DeviceId == request.DeviceId &&
+ dr.PersianYear == request.Year &&
+ dr.PersianMonth == request.Month)
+ .OrderBy(dr => dr.PersianDay)
+ .Select(dr => new { dr.PersianDate, dr.Analysis })
+ .ToListAsync(cancellationToken);
+
+ if (dailyReports.Count == 0)
+ {
+ throw new InvalidOperationException(
+ $"هیچ تحلیل روزانهای برای دستگاه {request.DeviceId} در ماه {request.Month} سال {request.Year} یافت نشد");
+ }
+
+ // Build summary of daily analyses
+ var summaryBuilder = new StringBuilder();
+ summaryBuilder.AppendLine($"تحلیلهای روزانه ماه {request.Month} سال {request.Year}:");
+ summaryBuilder.AppendLine();
+
+ foreach (var report in dailyReports)
+ {
+ summaryBuilder.AppendLine($"📅 {report.PersianDate}:");
+ summaryBuilder.AppendLine(report.Analysis);
+ summaryBuilder.AppendLine();
+ summaryBuilder.AppendLine("---");
+ summaryBuilder.AppendLine();
+ }
+
+ // Prepare AI prompt
+ var productTypeInfo = !string.IsNullOrWhiteSpace(deviceSettings?.ProductType)
+ ? $" محصول کشت شده: {deviceSettings.ProductType}."
+ : string.Empty;
+
+ var areaInfo = deviceSettings?.AreaSquareMeters != null
+ ? $" مساحت گلخانه: {deviceSettings.AreaSquareMeters:F1} متر مربع."
+ : string.Empty;
+
+ var question = $@"این تحلیلهای روزانه یک ماه ({request.Month}/{request.Year}) از یک گلخانه هوشمند هستند.{productTypeInfo}{areaInfo}
+
+{summaryBuilder}
+
+لطفاً یک تحلیل جامع ماهانه بده که شامل:
+1. خلاصه کلی عملکرد ماه
+2. روندهای اصلی و تغییرات مهم
+3. نقاط قوت و ضعف
+4. توصیههای کلیدی برای ماه آینده
+5. نکات مهم برای بهبود بهرهوری
+
+جامع و کاربردی باش (حداکثر 800 کلمه).";
+
+ var systemMessage = !string.IsNullOrWhiteSpace(deviceSettings?.ProductType)
+ ? $"تو یک متخصص کشاورزی و گلخانه هستی که در کشت {deviceSettings.ProductType} تخصص داری. تحلیلهای روزانه رو بررسی کن و یک جمعبندی ماهانه جامع ارائه بده."
+ : "تو یک متخصص کشاورزی و گلخانه هستی. تحلیلهای روزانه رو بررسی کن و یک جمعبندی ماهانه جامع ارائه بده.";
+
+ // Send to DeepSeek
+ var stopwatch = Stopwatch.StartNew();
+ var chatRequest = new ChatRequest
+ {
+ Model = "deepseek-chat",
+ Messages = new List
+ {
+ new() { Role = "system", Content = systemMessage },
+ new() { Role = "user", Content = question }
+ },
+ Temperature = 0.7
+ };
+
+ var aiResponse = await _deepSeekService.AskAsync(chatRequest, cancellationToken);
+ stopwatch.Stop();
+
+ if (aiResponse?.Choices == null || aiResponse.Choices.Count == 0 ||
+ string.IsNullOrWhiteSpace(aiResponse.Choices[0].Message?.Content))
+ {
+ throw new InvalidOperationException("پاسخ نامعتبر از سرویس هوش مصنوعی");
+ }
+
+ return new DailyReportResponse
+ {
+ Id = 0,
+ DeviceId = request.DeviceId,
+ DeviceName = device.DeviceName,
+ PersianDate = $"ماه {request.Month} سال {request.Year}",
+ Analysis = aiResponse.Choices[0].Message!.Content,
+ RecordCount = dailyReports.Count,
+ SampledRecordCount = dailyReports.Count,
+ TotalTokens = aiResponse.Usage?.TotalTokens ?? 0,
+ CreatedAt = DateTime.UtcNow,
+ FromCache = false
+ };
+ }
}
diff --git a/src/GreenHome.Infrastructure/DevicePostService.cs b/src/GreenHome.Infrastructure/DevicePostService.cs
new file mode 100644
index 0000000..39b9139
--- /dev/null
+++ b/src/GreenHome.Infrastructure/DevicePostService.cs
@@ -0,0 +1,185 @@
+using AutoMapper;
+using GreenHome.Application;
+using Microsoft.EntityFrameworkCore;
+
+namespace GreenHome.Infrastructure;
+
+public sealed class DevicePostService : IDevicePostService
+{
+ private readonly GreenHomeDbContext _context;
+ private readonly IMapper _mapper;
+
+ public DevicePostService(GreenHomeDbContext context, IMapper mapper)
+ {
+ _context = context;
+ _mapper = mapper;
+ }
+
+ public async Task> GetPostsAsync(
+ DevicePostFilter filter,
+ CancellationToken cancellationToken)
+ {
+ var query = _context.DevicePosts
+ .Include(p => p.AuthorUser)
+ .Include(p => p.Images)
+ .AsNoTracking()
+ .Where(p => p.DeviceId == filter.DeviceId);
+
+ if (filter.AuthorUserId.HasValue)
+ {
+ query = query.Where(p => p.AuthorUserId == filter.AuthorUserId.Value);
+ }
+
+ var totalCount = await query.CountAsync(cancellationToken);
+
+ var posts = await query
+ .OrderByDescending(p => p.CreatedAt)
+ .Skip((filter.Page - 1) * filter.PageSize)
+ .Take(filter.PageSize)
+ .ToListAsync(cancellationToken);
+
+ var dtos = _mapper.Map>(posts);
+
+ return new PagedResult
+ {
+ Items = dtos,
+ TotalCount = totalCount,
+ Page = filter.Page,
+ PageSize = filter.PageSize
+ };
+ }
+
+ public async Task GetPostByIdAsync(int id, CancellationToken cancellationToken)
+ {
+ var post = await _context.DevicePosts
+ .Include(p => p.AuthorUser)
+ .Include(p => p.Images)
+ .AsNoTracking()
+ .FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
+
+ return post != null ? _mapper.Map(post) : null;
+ }
+
+ public async Task CreatePostAsync(
+ CreateDevicePostRequest request,
+ CancellationToken cancellationToken)
+ {
+ // Verify user has access to device
+ var hasAccess = await CanUserAccessDeviceAsync(request.AuthorUserId, request.DeviceId, cancellationToken);
+ if (!hasAccess)
+ {
+ throw new UnauthorizedAccessException("کاربر به این دستگاه دسترسی ندارد");
+ }
+
+ var post = new Domain.DevicePost
+ {
+ DeviceId = request.DeviceId,
+ AuthorUserId = request.AuthorUserId,
+ Content = request.Content,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ _context.DevicePosts.Add(post);
+ await _context.SaveChangesAsync(cancellationToken);
+
+ return post.Id;
+ }
+
+ public async Task UpdatePostAsync(
+ UpdateDevicePostRequest request,
+ CancellationToken cancellationToken)
+ {
+ var post = await _context.DevicePosts
+ .FirstOrDefaultAsync(p => p.Id == request.Id, cancellationToken);
+
+ if (post == null)
+ {
+ throw new InvalidOperationException($"پست با شناسه {request.Id} یافت نشد");
+ }
+
+ post.Content = request.Content;
+ post.UpdatedAt = DateTime.UtcNow;
+
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task DeletePostAsync(int id, CancellationToken cancellationToken)
+ {
+ var post = await _context.DevicePosts
+ .Include(p => p.Images)
+ .FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
+
+ if (post == null)
+ {
+ throw new InvalidOperationException($"پست با شناسه {id} یافت نشد");
+ }
+
+ _context.DevicePosts.Remove(post);
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task AddImageToPostAsync(
+ int postId,
+ string fileName,
+ string filePath,
+ string contentType,
+ long fileSize,
+ CancellationToken cancellationToken)
+ {
+ var post = await _context.DevicePosts
+ .FirstOrDefaultAsync(p => p.Id == postId, cancellationToken);
+
+ if (post == null)
+ {
+ throw new InvalidOperationException($"پست با شناسه {postId} یافت نشد");
+ }
+
+ var image = new Domain.DevicePostImage
+ {
+ DevicePostId = postId,
+ FileName = fileName,
+ FilePath = filePath,
+ ContentType = contentType,
+ FileSize = fileSize,
+ UploadedAt = DateTime.UtcNow
+ };
+
+ _context.DevicePostImages.Add(image);
+ await _context.SaveChangesAsync(cancellationToken);
+
+ return image.Id;
+ }
+
+ public async Task DeleteImageAsync(int imageId, CancellationToken cancellationToken)
+ {
+ var image = await _context.DevicePostImages
+ .FirstOrDefaultAsync(i => i.Id == imageId, cancellationToken);
+
+ if (image == null)
+ {
+ throw new InvalidOperationException($"تصویر با شناسه {imageId} یافت نشد");
+ }
+
+ _context.DevicePostImages.Remove(image);
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task CanUserAccessDeviceAsync(
+ int userId,
+ int deviceId,
+ CancellationToken cancellationToken)
+ {
+ // Check if user is the device owner or has access through DeviceUsers
+ var hasAccess = await _context.Devices
+ .AnyAsync(d => d.Id == deviceId && d.UserId == userId, cancellationToken);
+
+ if (!hasAccess)
+ {
+ hasAccess = await _context.DeviceUsers
+ .AnyAsync(du => du.DeviceId == deviceId && du.UserId == userId, cancellationToken);
+ }
+
+ return hasAccess;
+ }
+}
+
diff --git a/src/GreenHome.Infrastructure/GreenHomeDbContext.cs b/src/GreenHome.Infrastructure/GreenHomeDbContext.cs
index 5fe4901..fc07242 100644
--- a/src/GreenHome.Infrastructure/GreenHomeDbContext.cs
+++ b/src/GreenHome.Infrastructure/GreenHomeDbContext.cs
@@ -15,8 +15,17 @@ public sealed class GreenHomeDbContext : DbContext
public DbSet VerificationCodes => Set();
public DbSet DeviceUsers => Set();
public DbSet AlertNotifications => Set();
+ public DbSet AlertLogs => Set();
public DbSet AIQueries => Set();
public DbSet DailyReports => Set();
+ public DbSet UserDailyReports => Set();
+ public DbSet ReportImages => Set();
+ public DbSet Checklists => Set();
+ public DbSet ChecklistItems => Set();
+ public DbSet ChecklistCompletions => Set();
+ public DbSet ChecklistItemCompletions => Set();
+ public DbSet DevicePosts => Set();
+ public DbSet DevicePostImages => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -58,6 +67,10 @@ public sealed class GreenHomeDbContext : DbContext
b.Property(x => x.City).HasMaxLength(100);
b.Property(x => x.Latitude).HasColumnType("decimal(9,6)");
b.Property(x => x.Longitude).HasColumnType("decimal(9,6)");
+ b.Property(x => x.ProductType).HasMaxLength(100);
+ b.Property(x => x.MinimumSmsIntervalMinutes).HasDefaultValue(15);
+ b.Property(x => x.MinimumCallIntervalMinutes).HasDefaultValue(60);
+ b.Property(x => x.AreaSquareMeters).HasColumnType("decimal(18,2)");
b.HasOne(x => x.Device)
.WithMany()
.HasForeignKey(x => x.DeviceId)
@@ -112,6 +125,7 @@ public sealed class GreenHomeDbContext : DbContext
{
b.ToTable("DeviceUsers");
b.HasKey(x => new { x.DeviceId, x.UserId });
+ b.Property(x => x.ReceiveAlerts).IsRequired().HasDefaultValue(true);
b.HasOne(x => x.Device)
.WithMany(d => d.DeviceUsers)
.HasForeignKey(x => x.DeviceId)
@@ -189,5 +203,169 @@ public sealed class GreenHomeDbContext : DbContext
b.HasIndex(x => new { x.DeviceId, x.PersianYear, x.PersianMonth });
b.HasIndex(x => x.CreatedAt);
});
+
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable("AlertLogs");
+ b.HasKey(x => x.Id);
+ b.Property(x => x.AlertType).IsRequired().HasConversion();
+ b.Property(x => x.NotificationType).IsRequired().HasConversion();
+ b.Property(x => x.Message).IsRequired().HasMaxLength(1000);
+ b.Property(x => x.Status).IsRequired().HasConversion();
+ b.Property(x => x.ErrorMessage).HasMaxLength(2000);
+ b.Property(x => x.PhoneNumber).IsRequired().HasMaxLength(20);
+ 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.HasOne(x => x.AlertCondition)
+ .WithMany()
+ .HasForeignKey(x => x.AlertConditionId)
+ .OnDelete(DeleteBehavior.SetNull);
+ b.HasIndex(x => new { x.DeviceId, x.SentAt });
+ b.HasIndex(x => new { x.UserId, x.SentAt });
+ b.HasIndex(x => x.AlertType);
+ b.HasIndex(x => x.Status);
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable("UserDailyReports");
+ b.HasKey(x => x.Id);
+ b.Property(x => x.PersianDate).IsRequired().HasMaxLength(10);
+ b.Property(x => x.Title).IsRequired().HasMaxLength(200);
+ b.Property(x => x.Observations).IsRequired();
+ b.Property(x => x.Operations).IsRequired();
+ b.Property(x => x.Notes).HasMaxLength(2000);
+ b.HasOne(x => x.Device)
+ .WithMany()
+ .HasForeignKey(x => x.DeviceId)
+ .OnDelete(DeleteBehavior.Cascade);
+ b.HasOne(x => x.User)
+ .WithMany()
+ .HasForeignKey(x => x.UserId)
+ .OnDelete(DeleteBehavior.Restrict);
+ b.HasIndex(x => new { x.DeviceId, x.PersianDate });
+ b.HasIndex(x => new { x.DeviceId, x.PersianYear, x.PersianMonth });
+ b.HasIndex(x => x.UserId);
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable("ReportImages");
+ b.HasKey(x => x.Id);
+ b.Property(x => x.FileName).IsRequired().HasMaxLength(255);
+ b.Property(x => x.FilePath).IsRequired().HasMaxLength(500);
+ b.Property(x => x.ContentType).IsRequired().HasMaxLength(100);
+ b.Property(x => x.Description).HasMaxLength(500);
+ b.HasOne(x => x.UserDailyReport)
+ .WithMany(r => r.Images)
+ .HasForeignKey(x => x.UserDailyReportId)
+ .OnDelete(DeleteBehavior.Cascade);
+ b.HasIndex(x => x.UserDailyReportId);
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable("Checklists");
+ b.HasKey(x => x.Id);
+ b.Property(x => x.Title).IsRequired().HasMaxLength(200);
+ b.Property(x => x.Description).HasMaxLength(1000);
+ b.Property(x => x.IsActive).IsRequired();
+ b.HasOne(x => x.Device)
+ .WithMany()
+ .HasForeignKey(x => x.DeviceId)
+ .OnDelete(DeleteBehavior.Cascade);
+ b.HasOne(x => x.CreatedByUser)
+ .WithMany()
+ .HasForeignKey(x => x.CreatedByUserId)
+ .OnDelete(DeleteBehavior.Restrict);
+ b.HasIndex(x => new { x.DeviceId, x.IsActive });
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable("ChecklistItems");
+ b.HasKey(x => x.Id);
+ b.Property(x => x.Title).IsRequired().HasMaxLength(300);
+ b.Property(x => x.Description).HasMaxLength(1000);
+ b.Property(x => x.Order).IsRequired();
+ b.Property(x => x.IsRequired).IsRequired();
+ b.HasOne(x => x.Checklist)
+ .WithMany(c => c.Items)
+ .HasForeignKey(x => x.ChecklistId)
+ .OnDelete(DeleteBehavior.Cascade);
+ b.HasIndex(x => new { x.ChecklistId, x.Order });
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable("ChecklistCompletions");
+ b.HasKey(x => x.Id);
+ b.Property(x => x.PersianDate).IsRequired().HasMaxLength(10);
+ b.Property(x => x.Notes).HasMaxLength(2000);
+ b.HasOne(x => x.Checklist)
+ .WithMany(c => c.Completions)
+ .HasForeignKey(x => x.ChecklistId)
+ .OnDelete(DeleteBehavior.Cascade);
+ b.HasOne(x => x.CompletedByUser)
+ .WithMany()
+ .HasForeignKey(x => x.CompletedByUserId)
+ .OnDelete(DeleteBehavior.Restrict);
+ b.HasIndex(x => new { x.ChecklistId, x.PersianDate });
+ b.HasIndex(x => x.CompletedByUserId);
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable("ChecklistItemCompletions");
+ b.HasKey(x => x.Id);
+ b.Property(x => x.IsChecked).IsRequired();
+ b.Property(x => x.Note).HasMaxLength(500);
+ b.HasOne(x => x.ChecklistCompletion)
+ .WithMany(cc => cc.ItemCompletions)
+ .HasForeignKey(x => x.ChecklistCompletionId)
+ .OnDelete(DeleteBehavior.Cascade);
+ b.HasOne(x => x.ChecklistItem)
+ .WithMany()
+ .HasForeignKey(x => x.ChecklistItemId)
+ .OnDelete(DeleteBehavior.Restrict);
+ b.HasIndex(x => x.ChecklistCompletionId);
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable("DevicePosts");
+ b.HasKey(x => x.Id);
+ b.Property(x => x.Content).IsRequired().HasMaxLength(5000);
+ b.HasOne(x => x.Device)
+ .WithMany()
+ .HasForeignKey(x => x.DeviceId)
+ .OnDelete(DeleteBehavior.Cascade);
+ b.HasOne(x => x.AuthorUser)
+ .WithMany()
+ .HasForeignKey(x => x.AuthorUserId)
+ .OnDelete(DeleteBehavior.Restrict);
+ b.HasIndex(x => new { x.DeviceId, x.CreatedAt });
+ b.HasIndex(x => x.AuthorUserId);
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable("DevicePostImages");
+ b.HasKey(x => x.Id);
+ b.Property(x => x.FileName).IsRequired().HasMaxLength(255);
+ b.Property(x => x.FilePath).IsRequired().HasMaxLength(500);
+ b.Property(x => x.ContentType).IsRequired().HasMaxLength(100);
+ b.HasOne(x => x.DevicePost)
+ .WithMany(p => p.Images)
+ .HasForeignKey(x => x.DevicePostId)
+ .OnDelete(DeleteBehavior.Cascade);
+ b.HasIndex(x => x.DevicePostId);
+ });
}
}
diff --git a/src/GreenHome.Infrastructure/Migrations/20251216204600_AddAllNewFeatures.Designer.cs b/src/GreenHome.Infrastructure/Migrations/20251216204600_AddAllNewFeatures.Designer.cs
new file mode 100644
index 0000000..d795020
--- /dev/null
+++ b/src/GreenHome.Infrastructure/Migrations/20251216204600_AddAllNewFeatures.Designer.cs
@@ -0,0 +1,1199 @@
+//
+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("20251216204600_AddAllNewFeatures")]
+ partial class AddAllNewFeatures
+ {
+ ///
+ 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.AIQuery", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Answer")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CompletionTokens")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeviceId")
+ .HasColumnType("int");
+
+ b.Property("Model")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("PromptTokens")
+ .HasColumnType("int");
+
+ b.Property("Question")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ResponseTimeMs")
+ .HasColumnType("bigint");
+
+ b.Property("Temperature")
+ .HasColumnType("float");
+
+ b.Property("TotalTokens")
+ .HasColumnType("int");
+
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("DeviceId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AIQueries", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.AlertCondition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CallCooldownMinutes")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeviceId")
+ .HasColumnType("int");
+
+ b.Property("IsEnabled")
+ .HasColumnType("bit");
+
+ b.Property("NotificationType")
+ .HasColumnType("int");
+
+ b.Property("SmsCooldownMinutes")
+ .HasColumnType("int");
+
+ b.Property("TimeType")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId");
+
+ b.ToTable("AlertConditions", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.AlertLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AlertConditionId")
+ .HasColumnType("int");
+
+ b.Property("AlertType")
+ .HasColumnType("int");
+
+ b.Property("DeviceId")
+ .HasColumnType("int");
+
+ b.Property("ErrorMessage")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("NotificationType")
+ .HasColumnType("int");
+
+ b.Property("PhoneNumber")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("nvarchar(20)");
+
+ b.Property("ProcessingTimeMs")
+ .HasColumnType("bigint");
+
+ b.Property("SentAt")
+ .HasColumnType("datetime2");
+
+ b.Property("Status")
+ .HasColumnType("int");
+
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AlertConditionId");
+
+ b.HasIndex("AlertType");
+
+ b.HasIndex("Status");
+
+ b.HasIndex("DeviceId", "SentAt");
+
+ b.HasIndex("UserId", "SentAt");
+
+ b.ToTable("AlertLogs", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.AlertNotification", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AlertConditionId")
+ .HasColumnType("int");
+
+ b.Property("DeviceId")
+ .HasColumnType("int");
+
+ b.Property("ErrorMessage")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("IsSent")
+ .HasColumnType("bit");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("MessageOutboxIds")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("NotificationType")
+ .HasColumnType("int");
+
+ b.Property("SentAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AlertConditionId");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("DeviceId", "UserId", "AlertConditionId", "SentAt");
+
+ b.ToTable("AlertNotifications", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.AlertRule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AlertConditionId")
+ .HasColumnType("int");
+
+ b.Property("ComparisonType")
+ .HasColumnType("int");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("SensorType")
+ .HasColumnType("int");
+
+ b.Property("Value1")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Value2")
+ .HasColumnType("decimal(18,2)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AlertConditionId");
+
+ b.ToTable("AlertRules", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.Checklist", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("int");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("DeviceId")
+ .HasColumnType("int");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedByUserId");
+
+ b.HasIndex("DeviceId", "IsActive");
+
+ b.ToTable("Checklists", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.ChecklistCompletion", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ChecklistId")
+ .HasColumnType("int");
+
+ b.Property("CompletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CompletedByUserId")
+ .HasColumnType("int");
+
+ b.Property("Notes")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("PersianDate")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("nvarchar(10)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CompletedByUserId");
+
+ b.HasIndex("ChecklistId", "PersianDate");
+
+ b.ToTable("ChecklistCompletions", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.ChecklistItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ChecklistId")
+ .HasColumnType("int");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("IsRequired")
+ .HasColumnType("bit");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("nvarchar(300)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChecklistId", "Order");
+
+ b.ToTable("ChecklistItems", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.ChecklistItemCompletion", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ChecklistCompletionId")
+ .HasColumnType("int");
+
+ b.Property("ChecklistItemId")
+ .HasColumnType("int");
+
+ b.Property("IsChecked")
+ .HasColumnType("bit");
+
+ b.Property("Note")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChecklistCompletionId");
+
+ b.HasIndex("ChecklistItemId");
+
+ b.ToTable("ChecklistItemCompletions", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.DailyReport", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Analysis")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CompletionTokens")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeviceId")
+ .HasColumnType("int");
+
+ b.Property("Model")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property