diff --git a/src/GreenHome.Api/Controllers/AdminController.cs b/src/GreenHome.Api/Controllers/AdminController.cs index ed8a370..ef99f8b 100644 --- a/src/GreenHome.Api/Controllers/AdminController.cs +++ b/src/GreenHome.Api/Controllers/AdminController.cs @@ -123,4 +123,96 @@ public sealed class AdminController : ControllerBase var result = await _adminService.GetUsageAsync(from, to, userId, cancellationToken); return Ok(result); } + + // ==================== Alerts ==================== + + [HttpGet("alerts")] + public async Task>> Alerts( + [FromQuery] int? deviceId, + [FromQuery] Domain.AlertStatus? status, + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + var filter = new AdminAlertFilter + { + DeviceId = deviceId, + Status = status, + From = from, + To = to, + Page = Math.Max(1, page), + PageSize = Math.Clamp(pageSize, 1, 100) + }; + + var result = await _adminService.GetAlertsAsync(filter, cancellationToken); + return Ok(result); + } + + // ==================== Posts ==================== + + [HttpGet("posts")] + public async Task>> Posts( + [FromQuery] int? deviceId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + var filter = new AdminPostFilter + { + DeviceId = deviceId, + Page = Math.Max(1, page), + PageSize = Math.Clamp(pageSize, 1, 100) + }; + + var result = await _adminService.GetPostsAsync(filter, cancellationToken); + return Ok(result); + } + + [HttpDelete("posts/{id:int}")] + public async Task DeletePost(int id, CancellationToken cancellationToken) + { + var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id; + var deleted = await _adminService.DeletePostAsync(id, currentAdminId, cancellationToken); + return deleted ? Ok(new { success = true }) : NotFound(); + } + + // ==================== Device Debug ==================== + + [HttpPost("devices/{id:int}/test-sms")] + public async Task> TestDeviceSms(int id, CancellationToken cancellationToken) + { + var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id; + var result = await _adminService.TestDeviceSmsAsync(id, currentAdminId, cancellationToken); + return Ok(result); + } + + [HttpPost("devices/{id:int}/test-voice")] + public async Task> TestDeviceVoice(int id, CancellationToken cancellationToken) + { + var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id; + var result = await _adminService.TestDeviceVoiceAsync(id, currentAdminId, cancellationToken); + return Ok(result); + } + + // ==================== Audit Log ==================== + + [HttpGet("audit-logs")] + public async Task>> AuditLogs( + [FromQuery] int? adminId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + var filter = new AdminAuditLogFilter + { + AdminId = adminId, + Page = Math.Max(1, page), + PageSize = Math.Clamp(pageSize, 1, 100) + }; + + var result = await _adminService.GetAuditLogsAsync(filter, cancellationToken); + return Ok(result); + } } diff --git a/src/GreenHome.Application/Dtos/Admin/AdminDtos.cs b/src/GreenHome.Application/Dtos/Admin/AdminDtos.cs index 6054ccd..a048060 100644 --- a/src/GreenHome.Application/Dtos/Admin/AdminDtos.cs +++ b/src/GreenHome.Application/Dtos/Admin/AdminDtos.cs @@ -127,3 +127,77 @@ public sealed class UsageDayDto public int AiQueryCount { get; set; } public int AiTokenCount { get; set; } } + +// ==================== Alerts ==================== + +public sealed class AdminAlertFilter +{ + public int? DeviceId { get; set; } + public Domain.AlertStatus? Status { get; set; } + public DateTime? From { get; set; } + public DateTime? To { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 20; +} + +public sealed class AdminAlertItemDto +{ + public int Id { get; set; } + public int DeviceId { get; set; } + public string DeviceName { get; set; } = string.Empty; + public string OwnerName { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public Domain.AlertNotificationType NotificationType { get; set; } + public Domain.AlertStatus Status { get; set; } + public string? ErrorMessage { get; set; } + public DateTime SentAt { get; set; } +} + +// ==================== Posts ==================== + +public sealed class AdminPostFilter +{ + public int? DeviceId { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 20; +} + +public sealed class AdminPostItemDto +{ + public int Id { get; set; } + public int DeviceId { get; set; } + public string DeviceName { get; set; } = string.Empty; + public string AuthorName { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public Domain.DevicePostType PostType { get; set; } + public int ImageCount { get; set; } + public DateTime CreatedAt { get; set; } +} + +// ==================== Device Debug ==================== + +public sealed class AdminDeviceTestResultDto +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; +} + +// ==================== Audit Log ==================== + +public sealed class AdminAuditLogFilter +{ + public int? AdminId { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 20; +} + +public sealed class AdminAuditLogDto +{ + public int Id { get; set; } + public int AdminUserId { get; set; } + public string AdminName { get; set; } = string.Empty; + public string Action { get; set; } = string.Empty; + public string TargetId { get; set; } = string.Empty; + public string Details { get; set; } = string.Empty; + public DateTime At { get; set; } +} diff --git a/src/GreenHome.Application/Interfaces/IAdminService.cs b/src/GreenHome.Application/Interfaces/IAdminService.cs index b85d4b4..742eb6d 100644 --- a/src/GreenHome.Application/Interfaces/IAdminService.cs +++ b/src/GreenHome.Application/Interfaces/IAdminService.cs @@ -12,4 +12,10 @@ public interface IAdminService Task> GetDevicesAsync(AdminDeviceFilter filter, CancellationToken cancellationToken); Task GetDeviceAsync(int id, CancellationToken cancellationToken); Task> GetUsageAsync(DateTime? from, DateTime? to, int? userId, CancellationToken cancellationToken); + Task> GetAlertsAsync(AdminAlertFilter filter, CancellationToken cancellationToken); + Task> GetPostsAsync(AdminPostFilter filter, CancellationToken cancellationToken); + Task DeletePostAsync(int id, int currentAdminId, CancellationToken cancellationToken); + Task TestDeviceSmsAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken); + Task TestDeviceVoiceAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken); + Task> GetAuditLogsAsync(AdminAuditLogFilter filter, CancellationToken cancellationToken); } diff --git a/src/GreenHome.Domain/AdminAuditLog.cs b/src/GreenHome.Domain/AdminAuditLog.cs new file mode 100644 index 0000000..7522df9 --- /dev/null +++ b/src/GreenHome.Domain/AdminAuditLog.cs @@ -0,0 +1,35 @@ +namespace GreenHome.Domain; + +/// +/// لاگ عملیات مدیریتی پنل admin (تغییر نقش/وضعیت کاربر، حذف پست، تست SMS/صوتی و ...) +/// +public sealed class AdminAuditLog +{ + public int Id { get; set; } + + /// + /// شناسه مدیری که عملیات را انجام داده + /// + public int AdminUserId { get; set; } + public User AdminUser { get; set; } = null!; + + /// + /// نام عملیات به فرمت «Entity.Action» (مثلاً User.SetRole, Post.Delete, Device.TestSms) + /// + public string Action { get; set; } = string.Empty; + + /// + /// شناسه هدف (id کاربر/دستگاه/پست) + /// + public string TargetId { get; set; } = string.Empty; + + /// + /// جزئیات اضافی (JSON اختیاری) + /// + public string Details { get; set; } = string.Empty; + + /// + /// زمان عملیات (UTC) + /// + public DateTime At { get; set; } +} diff --git a/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs b/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs index cf258a0..8197cb2 100644 --- a/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs +++ b/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs @@ -31,6 +31,7 @@ public sealed class GreenHomeDbContext : DbContext public DbSet DevicePostImages => Set(); public DbSet PowerOutageLogs => Set(); public DbSet UsageCounters => Set(); + public DbSet AdminAuditLogs => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -458,5 +459,20 @@ public sealed class GreenHomeDbContext : DbContext b.HasIndex(x => new { x.UserId, x.Date }).IsUnique(); b.HasIndex(x => x.Date); }); + + modelBuilder.Entity(b => + { + b.ToTable("AdminAuditLogs"); + b.HasKey(x => x.Id); + b.Property(x => x.Action).IsRequired().HasMaxLength(100); + b.Property(x => x.TargetId).IsRequired().HasMaxLength(100); + b.Property(x => x.Details).HasMaxLength(2000); + b.HasOne(x => x.AdminUser) + .WithMany() + .HasForeignKey(x => x.AdminUserId) + .OnDelete(DeleteBehavior.Restrict); + b.HasIndex(x => x.At); + b.HasIndex(x => x.AdminUserId); + }); } } diff --git a/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925115448_AdminPanelPhase2.Designer.cs b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925115448_AdminPanelPhase2.Designer.cs new file mode 100644 index 0000000..23408c2 --- /dev/null +++ b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925115448_AdminPanelPhase2.Designer.cs @@ -0,0 +1,1597 @@ +// +using System; +using GreenHome.Infrastructure.Data; +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.Data.Migrations.Migrations +{ + [DbContext(typeof(GreenHomeDbContext))] + [Migration("20260925115448_AdminPanelPhase2")] + partial class AdminPanelPhase2 + { + /// + 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.AdminAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AdminUserId") + .HasColumnType("int"); + + b.Property("At") + .HasColumnType("datetime2"); + + b.Property("Details") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("AdminUserId"); + + b.HasIndex("At"); + + b.ToTable("AdminAuditLogs", (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("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("PersianDay") + .HasColumnType("int"); + + b.Property("PersianMonth") + .HasColumnType("int"); + + b.Property("PersianYear") + .HasColumnType("int"); + + b.Property("PromptTokens") + .HasColumnType("int"); + + b.Property("RecordCount") + .HasColumnType("int"); + + b.Property("ResponseTimeMs") + .HasColumnType("bigint"); + + b.Property("SampledRecordCount") + .HasColumnType("int"); + + b.Property("TotalTokens") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DeviceId", "PersianDate") + .IsUnique(); + + b.HasIndex("DeviceId", "PersianYear", "PersianMonth"); + + b.ToTable("DailyReports", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("nvarchar(250)"); + + b.Property("NeshanLocation") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Devices", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DevicePost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthorUserId") + .HasColumnType("int"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(5000) + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("PostType") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VoiceDurationSeconds") + .HasColumnType("int"); + + b.Property("VoiceFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorUserId"); + + b.HasIndex("DeviceId", "CreatedAt"); + + b.ToTable("DevicePosts", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DevicePostImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DevicePostId") + .HasColumnType("int"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("UploadedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DevicePostId"); + + b.ToTable("DevicePostImages", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceRelay", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceSettingsId") + .HasColumnType("int"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RelayIndex") + .HasColumnType("int"); + + b.Property("RelayKind") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeviceSettingsId", "RelayIndex") + .IsUnique(); + + b.ToTable("DeviceRelays", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaSquareMeters") + .HasColumnType("decimal(18,2)"); + + b.Property("City") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("DeviceId1") + .HasColumnType("int"); + + b.Property("DevicePhoneNumber") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Latitude") + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasColumnType("decimal(9,6)"); + + b.Property("MinimumCallIntervalMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(60); + + b.Property("MinimumSmsIntervalMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(15); + + b.Property("ProductType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Province") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RelayCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(4); + + b.Property("SimCardType") + .HasColumnType("int"); + + b.Property("SmsRecievers") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("TokenCode") + .HasColumnType("nvarchar(max)"); + + b.Property("TokenExpiresAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UploadIntervalMin") + .HasColumnType("int"); + + b.Property("VerificationCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.HasIndex("DeviceId1"); + + b.ToTable("DeviceSettings", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceUser", b => + { + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("AvatarPath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Family") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ReceiveAlerts") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("DeviceId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("DeviceUsers", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.PowerOutageLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CurrentPower") + .HasColumnType("tinyint"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("PersianMonth") + .HasColumnType("int"); + + b.Property("PersianYear") + .HasColumnType("int"); + + b.Property("PreviousPower") + .HasColumnType("tinyint"); + + b.Property("ServerTimestampUtc") + .HasColumnType("datetime2"); + + b.Property("TimestampUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("EventType"); + + b.HasIndex("DeviceId", "TimestampUtc"); + + b.HasIndex("DeviceId", "PersianYear", "PersianMonth"); + + b.ToTable("PowerOutageLogs", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.RelayRuleCondition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComparisonType") + .HasColumnType("int"); + + b.Property("ConditionType") + .HasColumnType("int"); + + b.Property("EndMinutes") + .HasColumnType("int"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("RelayRuleGroupId") + .HasColumnType("int"); + + b.Property("SensorType") + .HasColumnType("int"); + + b.Property("StartMinutes") + .HasColumnType("int"); + + b.Property("Value1") + .HasColumnType("decimal(18,2)"); + + b.Property("Value2") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("RelayRuleGroupId"); + + b.ToTable("RelayRuleConditions", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("int"); + + b.Property("DeviceRelayId") + .HasColumnType("int"); + + b.Property("Label") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DeviceRelayId"); + + b.ToTable("RelayRuleGroups", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.ReportImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("UploadedAt") + .HasColumnType("datetime2"); + + b.Property("UserDailyReportId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserDailyReportId"); + + b.ToTable("ReportImages", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.TelemetryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BatteryPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("BatteryVoltage") + .HasColumnType("decimal(18,2)"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("GasPPM") + .HasColumnType("int"); + + b.Property("HumidityPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("Lux") + .HasColumnType("decimal(18,2)"); + + b.Property("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("PersianMonth") + .HasColumnType("int"); + + b.Property("PersianYear") + .HasColumnType("int"); + + b.Property("Power") + .HasColumnType("tinyint"); + + b.Property("RelayStates") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ServerTimestampUtc") + .HasColumnType("datetime2"); + + b.Property("SoilPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("TemperatureC") + .HasColumnType("decimal(18,2)"); + + b.Property("TimestampUtc") + .HasColumnType("datetime2"); + + b.Property("Voltage") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId", "ServerTimestampUtc"); + + b.HasIndex("DeviceId", "TimestampUtc"); + + b.HasIndex("DeviceId", "PersianYear", "PersianMonth"); + + b.ToTable("Telemetry", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.UsageCounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AiQueryCount") + .HasColumnType("int"); + + b.Property("AiTokenCount") + .HasColumnType("int"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("SmsCount") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("VoiceCallCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("UserId", "Date") + .IsUnique(); + + b.ToTable("UsageCounters", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Family") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsDisabled") + .HasColumnType("bit"); + + b.Property("LastLoginAt") + .HasColumnType("datetime2"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Mobile") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.UserDailyReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Observations") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Operations") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("PersianDay") + .HasColumnType("int"); + + b.Property("PersianMonth") + .HasColumnType("int"); + + b.Property("PersianYear") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("DeviceId", "PersianDate"); + + b.HasIndex("DeviceId", "PersianYear", "PersianMonth"); + + b.ToTable("UserDailyReports", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.VerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsUsed") + .HasColumnType("bit"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("UsedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Mobile", "Code", "IsUsed"); + + b.ToTable("VerificationCodes", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.AIQuery", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.AdminAuditLog", b => + { + b.HasOne("GreenHome.Domain.User", "AdminUser") + .WithMany() + .HasForeignKey("AdminUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AdminUser"); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertCondition", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertLog", b => + { + b.HasOne("GreenHome.Domain.AlertCondition", "AlertCondition") + .WithMany() + .HasForeignKey("AlertConditionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AlertCondition"); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertNotification", b => + { + b.HasOne("GreenHome.Domain.AlertCondition", "AlertCondition") + .WithMany() + .HasForeignKey("AlertConditionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AlertCondition"); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertRule", b => + { + b.HasOne("GreenHome.Domain.AlertCondition", "AlertCondition") + .WithMany("Rules") + .HasForeignKey("AlertConditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AlertCondition"); + }); + + modelBuilder.Entity("GreenHome.Domain.Checklist", b => + { + b.HasOne("GreenHome.Domain.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistCompletion", b => + { + b.HasOne("GreenHome.Domain.Checklist", "Checklist") + .WithMany("Completions") + .HasForeignKey("ChecklistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "CompletedByUser") + .WithMany() + .HasForeignKey("CompletedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Checklist"); + + b.Navigation("CompletedByUser"); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistItem", b => + { + b.HasOne("GreenHome.Domain.Checklist", "Checklist") + .WithMany("Items") + .HasForeignKey("ChecklistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Checklist"); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistItemCompletion", b => + { + b.HasOne("GreenHome.Domain.ChecklistCompletion", "ChecklistCompletion") + .WithMany("ItemCompletions") + .HasForeignKey("ChecklistCompletionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.ChecklistItem", "ChecklistItem") + .WithMany() + .HasForeignKey("ChecklistItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ChecklistCompletion"); + + b.Navigation("ChecklistItem"); + }); + + modelBuilder.Entity("GreenHome.Domain.DailyReport", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.DevicePost", b => + { + b.HasOne("GreenHome.Domain.User", "AuthorUser") + .WithMany() + .HasForeignKey("AuthorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AuthorUser"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.DevicePostImage", b => + { + b.HasOne("GreenHome.Domain.DevicePost", "DevicePost") + .WithMany("Images") + .HasForeignKey("DevicePostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DevicePost"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceRelay", b => + { + b.HasOne("GreenHome.Domain.DeviceSettings", "DeviceSettings") + .WithMany("Relays") + .HasForeignKey("DeviceSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DeviceSettings"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.Device", null) + .WithMany("DeviceSettings") + .HasForeignKey("DeviceId1"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceUser", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany("DeviceUsers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany("DeviceUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.PowerOutageLog", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.RelayRuleCondition", b => + { + b.HasOne("GreenHome.Domain.RelayRuleGroup", "RelayRuleGroup") + .WithMany("Conditions") + .HasForeignKey("RelayRuleGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RelayRuleGroup"); + }); + + modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b => + { + b.HasOne("GreenHome.Domain.DeviceRelay", "DeviceRelay") + .WithMany("RuleGroups") + .HasForeignKey("DeviceRelayId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DeviceRelay"); + }); + + modelBuilder.Entity("GreenHome.Domain.ReportImage", b => + { + b.HasOne("GreenHome.Domain.UserDailyReport", "UserDailyReport") + .WithMany("Images") + .HasForeignKey("UserDailyReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDailyReport"); + }); + + modelBuilder.Entity("GreenHome.Domain.UsageCounter", b => + { + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.UserDailyReport", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertCondition", b => + { + b.Navigation("Rules"); + }); + + modelBuilder.Entity("GreenHome.Domain.Checklist", b => + { + b.Navigation("Completions"); + + b.Navigation("Items"); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistCompletion", b => + { + b.Navigation("ItemCompletions"); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.Navigation("DeviceSettings"); + + b.Navigation("DeviceUsers"); + }); + + modelBuilder.Entity("GreenHome.Domain.DevicePost", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceRelay", b => + { + b.Navigation("RuleGroups"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => + { + b.Navigation("Relays"); + }); + + modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b => + { + b.Navigation("Conditions"); + }); + + modelBuilder.Entity("GreenHome.Domain.User", b => + { + b.Navigation("DeviceUsers"); + }); + + modelBuilder.Entity("GreenHome.Domain.UserDailyReport", b => + { + b.Navigation("Images"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925115448_AdminPanelPhase2.cs b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925115448_AdminPanelPhase2.cs new file mode 100644 index 0000000..b78354d --- /dev/null +++ b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925115448_AdminPanelPhase2.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GreenHome.Infrastructure.Data.Migrations.Migrations +{ + /// + public partial class AdminPanelPhase2 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AdminAuditLogs", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + AdminUserId = table.Column(type: "int", nullable: false), + Action = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + TargetId = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Details = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: false), + At = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AdminAuditLogs", x => x.Id); + table.ForeignKey( + name: "FK_AdminAuditLogs_Users_AdminUserId", + column: x => x.AdminUserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_AdminAuditLogs_AdminUserId", + table: "AdminAuditLogs", + column: "AdminUserId"); + + migrationBuilder.CreateIndex( + name: "IX_AdminAuditLogs_At", + table: "AdminAuditLogs", + column: "At"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AdminAuditLogs"); + } + } +} diff --git a/src/GreenHome.Infrastructure/Data/Migrations/Migrations/GreenHomeDbContextModelSnapshot.cs b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/GreenHomeDbContextModelSnapshot.cs index 541cd0e..a34e66c 100644 --- a/src/GreenHome.Infrastructure/Data/Migrations/Migrations/GreenHomeDbContextModelSnapshot.cs +++ b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/GreenHomeDbContextModelSnapshot.cs @@ -77,6 +77,44 @@ namespace GreenHome.Infrastructure.Data.Migrations b.ToTable("AIQueries", (string)null); }); + modelBuilder.Entity("GreenHome.Domain.AdminAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AdminUserId") + .HasColumnType("int"); + + b.Property("At") + .HasColumnType("datetime2"); + + b.Property("Details") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("AdminUserId"); + + b.HasIndex("At"); + + b.ToTable("AdminAuditLogs", (string)null); + }); + modelBuilder.Entity("GreenHome.Domain.AlertCondition", b => { b.Property("Id") @@ -1173,6 +1211,17 @@ namespace GreenHome.Infrastructure.Data.Migrations b.Navigation("User"); }); + modelBuilder.Entity("GreenHome.Domain.AdminAuditLog", b => + { + b.HasOne("GreenHome.Domain.User", "AdminUser") + .WithMany() + .HasForeignKey("AdminUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AdminUser"); + }); + modelBuilder.Entity("GreenHome.Domain.AlertCondition", b => { b.HasOne("GreenHome.Domain.Device", "Device") diff --git a/src/GreenHome.Infrastructure/GreenHome.Infrastructure.csproj b/src/GreenHome.Infrastructure/GreenHome.Infrastructure.csproj index a8832a8..0c35c59 100644 --- a/src/GreenHome.Infrastructure/GreenHome.Infrastructure.csproj +++ b/src/GreenHome.Infrastructure/GreenHome.Infrastructure.csproj @@ -7,6 +7,10 @@ false + + + + diff --git a/src/GreenHome.Infrastructure/Services/Admin/AdminService.cs b/src/GreenHome.Infrastructure/Services/Admin/AdminService.cs index 6790d4c..e4a2012 100644 --- a/src/GreenHome.Infrastructure/Services/Admin/AdminService.cs +++ b/src/GreenHome.Infrastructure/Services/Admin/AdminService.cs @@ -1,8 +1,13 @@ +using System.Text.Json; using AutoMapper; using GreenHome.Application; using GreenHome.Application.Dtos.Admin; using GreenHome.Domain; using GreenHome.Infrastructure.Data; +using GreenHome.Infrastructure.Services.Usage; +using GreenHome.Sms.Ippanel; +using GreenHome.VoiceCall.Avanak; +using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -12,12 +17,27 @@ public sealed class AdminService : IAdminService { private readonly GreenHomeDbContext _dbContext; private readonly IMapper _mapper; + private readonly ISmsService _smsService; + private readonly IVoiceCallService _voiceCallService; + private readonly UsageTracker _usageTracker; + private readonly IWebHostEnvironment _environment; private readonly int _onlineWindowMinutes; - public AdminService(GreenHomeDbContext dbContext, IMapper mapper, IConfiguration configuration) + public AdminService( + GreenHomeDbContext dbContext, + IMapper mapper, + IConfiguration configuration, + ISmsService smsService, + IVoiceCallService voiceCallService, + UsageTracker usageTracker, + IWebHostEnvironment environment) { _dbContext = dbContext; _mapper = mapper; + _smsService = smsService; + _voiceCallService = voiceCallService; + _usageTracker = usageTracker; + _environment = environment; _onlineWindowMinutes = configuration.GetValue("Admin:OnlineWindowMinutes", 15); } @@ -173,6 +193,8 @@ public sealed class AdminService : IAdminService } user.Role = role; + AddAuditLog(currentAdminId, "User.SetRole", id.ToString(), + JsonSerializer.Serialize(new { role = role.ToString(), roleId = (int)role })); await _dbContext.SaveChangesAsync(cancellationToken); return UserMutationOutcome.Ok; } @@ -193,6 +215,7 @@ public sealed class AdminService : IAdminService } user.IsDisabled = !isEnabled; + AddAuditLog(currentAdminId, isEnabled ? "User.Enable" : "User.Disable", id.ToString()); await _dbContext.SaveChangesAsync(cancellationToken); return UserMutationOutcome.Ok; } @@ -319,8 +342,280 @@ public sealed class AdminService : IAdminService }).ToList(); } + // ==================== Alerts ==================== + + public async Task> GetAlertsAsync(AdminAlertFilter filter, CancellationToken cancellationToken) + { + if (filter.Page <= 0) filter.Page = 1; + if (filter.PageSize <= 0) filter.PageSize = 20; + + IQueryable query = _dbContext.AlertLogs.AsNoTracking() + .Include(a => a.Device).ThenInclude(d => d.User); + + if (filter.DeviceId.HasValue) + query = query.Where(a => a.DeviceId == filter.DeviceId.Value); + if (filter.Status.HasValue) + query = query.Where(a => a.Status == filter.Status.Value); + if (filter.From.HasValue) + query = query.Where(a => a.SentAt >= filter.From.Value); + if (filter.To.HasValue) + query = query.Where(a => a.SentAt <= filter.To.Value); + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query + .OrderByDescending(a => a.SentAt) + .ThenByDescending(a => a.Id) + .Skip((filter.Page - 1) * filter.PageSize) + .Take(filter.PageSize) + .ToListAsync(cancellationToken); + + return new PagedResult + { + Items = items.Select(a => new AdminAlertItemDto + { + Id = a.Id, + DeviceId = a.DeviceId, + DeviceName = a.Device.DeviceName, + OwnerName = a.Device.User.Name + " " + a.Device.User.Family, + Message = a.Message, + NotificationType = a.NotificationType, + Status = a.Status, + ErrorMessage = a.ErrorMessage, + SentAt = a.SentAt + }).ToList(), + TotalCount = totalCount, + Page = filter.Page, + PageSize = filter.PageSize + }; + } + + // ==================== Posts ==================== + + public async Task> GetPostsAsync(AdminPostFilter filter, CancellationToken cancellationToken) + { + if (filter.Page <= 0) filter.Page = 1; + if (filter.PageSize <= 0) filter.PageSize = 20; + + IQueryable query = _dbContext.DevicePosts.AsNoTracking() + .Include(p => p.Device) + .Include(p => p.AuthorUser) + .Include(p => p.Images); + + if (filter.DeviceId.HasValue) + query = query.Where(p => p.DeviceId == filter.DeviceId.Value); + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query + .OrderByDescending(p => p.CreatedAt) + .ThenByDescending(p => p.Id) + .Skip((filter.Page - 1) * filter.PageSize) + .Take(filter.PageSize) + .ToListAsync(cancellationToken); + + return new PagedResult + { + // ponytail: like/reply count intentionally omitted — DevicePost has no such fields yet. + Items = items.Select(p => new AdminPostItemDto + { + Id = p.Id, + DeviceId = p.DeviceId, + DeviceName = p.Device.DeviceName, + AuthorName = p.AuthorUser.Name + " " + p.AuthorUser.Family, + Content = p.Content, + PostType = p.PostType, + ImageCount = p.Images.Count, + CreatedAt = p.CreatedAt + }).ToList(), + TotalCount = totalCount, + Page = filter.Page, + PageSize = filter.PageSize + }; + } + + public async Task DeletePostAsync(int id, int currentAdminId, CancellationToken cancellationToken) + { + var post = await _dbContext.DevicePosts + .Include(p => p.Images) + .FirstOrDefaultAsync(p => p.Id == id, cancellationToken); + if (post == null) return false; + + // remove image files from disc (best effort — a missing file must not block the delete) + var webRoot = _environment.WebRootPath ?? "wwwroot"; + foreach (var image in post.Images) + { + try + { + var fullPath = Path.Combine(webRoot, image.FilePath.TrimStart('/')); + if (File.Exists(fullPath)) File.Delete(fullPath); + } + catch + { + // file cleanup is best-effort + } + } + if (!string.IsNullOrWhiteSpace(post.VoiceFilePath)) + { + try + { + var voicePath = Path.Combine(webRoot, post.VoiceFilePath.TrimStart('/')); + if (File.Exists(voicePath)) File.Delete(voicePath); + } + catch + { + // file cleanup is best-effort + } + } + + AddAuditLog(currentAdminId, "Post.Delete", id.ToString()); + _dbContext.DevicePosts.Remove(post); + await _dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + // ==================== Device Debug ==================== + + public async Task TestDeviceSmsAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken) + { + var device = await _dbContext.Devices.AsNoTracking() + .Include(d => d.User) + .FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken); + if (device == null) + return new AdminDeviceTestResultDto { Success = false, Message = "دستگاه یافت نشد" }; + + var mobile = MobileNormalizer.Normalize(device.User.Mobile); + if (mobile.Length == 0) + return new AdminDeviceTestResultDto { Success = false, Message = "شماره موبایل مالک معتبر نیست" }; + + // persist the audit row up-front so it is recorded no matter how the send attempt ends + AddAuditLog(currentAdminId, "Device.TestSms", deviceId.ToString(), + JsonSerializer.Serialize(new { mobile })); + await _dbContext.SaveChangesAsync(cancellationToken); + + try + { + var result = await _smsService.SendWebserviceSmsAsync(new WebserviceSmsRequest + { + Recipient = mobile, + Message = $"پیامک تست GreenHome برای دستگاه {device.DeviceName} — {DateTime.UtcNow:yyyy/MM/dd HH:mm} UTC" + }, cancellationToken); + + if (result?.Meta.Status == true) + { + await _usageTracker.RecordAsync(device.User.Id, UsageKind.Sms, cancellationToken: cancellationToken); + return new AdminDeviceTestResultDto { Success = true, Message = $"پیامک تست برای {mobile} ارسال شد" }; + } + + var reason = result?.Meta.Message ?? "خطای نامشخص سرویس پیامک"; + return new AdminDeviceTestResultDto { Success = false, Message = reason }; + } + catch (Exception ex) + { + return new AdminDeviceTestResultDto { Success = false, Message = $"خطا در ارسال: {ex.Message}" }; + } + } + + public async Task TestDeviceVoiceAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken) + { + var device = await _dbContext.Devices.AsNoTracking() + .Include(d => d.User) + .FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken); + if (device == null) + return new AdminDeviceTestResultDto { Success = false, Message = "دستگاه یافت نشد" }; + + var mobile = MobileNormalizer.Normalize(device.User.Mobile); + if (mobile.Length == 0) + return new AdminDeviceTestResultDto { Success = false, Message = "شماره موبایل مالک معتبر نیست" }; + + // persist the audit row up-front so it is recorded no matter how the send attempt ends + AddAuditLog(currentAdminId, "Device.TestVoice", deviceId.ToString(), + JsonSerializer.Serialize(new { mobile })); + await _dbContext.SaveChangesAsync(cancellationToken); + + try + { + var result = await _voiceCallService.QuickSendWithTTSAsync(new QuickSendWithTTSRequest + { + Text = $"پیام صوتی تست از پنل مدیریت گری‌هوم برای دستگاه {device.DeviceName}", + Number = mobile, + ServerID = 0 + }, cancellationToken); + + if (result?.IsSuccess == true) + { + // ponytail: ثبت مصرف فقط وقتی call واقعی ارسال می‌شود (هماهنگ با AlertNotificationSender) + await _usageTracker.RecordAsync(device.User.Id, UsageKind.VoiceCall, cancellationToken: cancellationToken); + return new AdminDeviceTestResultDto { Success = true, Message = $"تماس صوتی تست برای {mobile} انجام شد" }; + } + + var reason = result == null ? "پاسخی از سرویس صوتی دریافت نشد" + : $"کد خطا: {result.ReturnValue}"; + return new AdminDeviceTestResultDto { Success = false, Message = reason }; + } + catch (Exception ex) + { + return new AdminDeviceTestResultDto { Success = false, Message = $"خطا در تماس صوتی: {ex.Message}" }; + } + } + + // ==================== Audit Log ==================== + + public async Task> GetAuditLogsAsync(AdminAuditLogFilter filter, CancellationToken cancellationToken) + { + if (filter.Page <= 0) filter.Page = 1; + if (filter.PageSize <= 0) filter.PageSize = 20; + + IQueryable query = _dbContext.AdminAuditLogs.AsNoTracking() + .Include(a => a.AdminUser); + + if (filter.AdminId.HasValue) + query = query.Where(a => a.AdminUserId == filter.AdminId.Value); + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query + .OrderByDescending(a => a.At) + .ThenByDescending(a => a.Id) + .Skip((filter.Page - 1) * filter.PageSize) + .Take(filter.PageSize) + .ToListAsync(cancellationToken); + + return new PagedResult + { + Items = items.Select(a => new AdminAuditLogDto + { + Id = a.Id, + AdminUserId = a.AdminUserId, + AdminName = a.AdminUser.Name + " " + a.AdminUser.Family, + Action = a.Action, + TargetId = a.TargetId, + Details = a.Details, + At = a.At + }).ToList(), + TotalCount = totalCount, + Page = filter.Page, + PageSize = filter.PageSize + }; + } + // ==================== Helpers ==================== + /// + /// یک ردیف audit log به change tracker اضافه می‌کند؛ با همان SaveChanges عملیات اصلی ذخیره می‌شود. + /// + private void AddAuditLog(int adminUserId, string action, string targetId, string details = "") + { + _dbContext.AdminAuditLogs.Add(new Domain.AdminAuditLog + { + AdminUserId = adminUserId, + Action = action, + TargetId = targetId, + Details = details, + At = DateTime.UtcNow + }); + } + /// /// آخرین تلیمتری هر دستگاه + نام مالک (برای لیست‌های admin). /// diff --git a/src/GreenHome.Web/src/App.tsx b/src/GreenHome.Web/src/App.tsx index 7d752e0..b4ba7e2 100644 --- a/src/GreenHome.Web/src/App.tsx +++ b/src/GreenHome.Web/src/App.tsx @@ -16,7 +16,10 @@ import AdminLayout from '@/app/admin/AdminLayout' import AdminDashboardPage from '@/app/admin/page' import AdminUsersPage from '@/app/admin/users/page' import AdminDevicesPage from '@/app/admin/devices/page' +import AdminAlertsPage from '@/app/admin/alerts/page' +import AdminPostsPage from '@/app/admin/posts/page' import AdminUsagePage from '@/app/admin/usage/page' +import AdminAuditPage from '@/app/admin/audit/page' function DeviceRedirect() { const location = useLocation() @@ -46,7 +49,10 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> + } /> } /> } /> diff --git a/src/GreenHome.Web/src/app/admin/AdminLayout.tsx b/src/GreenHome.Web/src/app/admin/AdminLayout.tsx index 4437b61..01b6c30 100644 --- a/src/GreenHome.Web/src/app/admin/AdminLayout.tsx +++ b/src/GreenHome.Web/src/app/admin/AdminLayout.tsx @@ -2,14 +2,17 @@ import { Outlet, useLocation } from 'react-router-dom' import { Link } from '@/lib/router' -import { LayoutDashboard, Users, Cpu, BarChart3, ArrowRight } from 'lucide-react' +import { LayoutDashboard, Users, Cpu, BarChart3, ArrowRight, Bell, MessageSquare, ScrollText } from 'lucide-react' import { cn } from '@/lib/utils' const NAV_ITEMS = [ { href: '/admin', label: 'داشبورد', icon: LayoutDashboard, exact: true }, { href: '/admin/users', label: 'کاربران', icon: Users, exact: false }, { href: '/admin/devices', label: 'دستگاه‌ها', icon: Cpu, exact: false }, + { href: '/admin/alerts', label: 'اعلان‌ها', icon: Bell, exact: false }, + { href: '/admin/posts', label: 'پست‌ها', icon: MessageSquare, exact: false }, { href: '/admin/usage', label: 'مصرف', icon: BarChart3, exact: false }, + { href: '/admin/audit', label: 'گزارش اعمال', icon: ScrollText, exact: false }, ] export default function AdminLayout() { diff --git a/src/GreenHome.Web/src/app/admin/alerts/page.tsx b/src/GreenHome.Web/src/app/admin/alerts/page.tsx new file mode 100644 index 0000000..8c7c574 --- /dev/null +++ b/src/GreenHome.Web/src/app/admin/alerts/page.tsx @@ -0,0 +1,215 @@ +'use client' + +import { useCallback, useEffect, useState, type ChangeEvent } from 'react' +import { Bell, MessageSquare, Phone, AlertTriangle } from 'lucide-react' +import { api, PagedResult, AdminAlertItem } from '@/lib/api' +import { toPersianDigits } from '@/lib/format/persian-digits' +import { formatPersianDateFromIso } from '@/lib/date/persian-date' +import { Pagination } from '@/components/navigation/Pagination' +import { Badge } from '@/components/common/Badge' +import { Button } from '@/components/common/Button' +import { EmptyState } from '@/components/common/EmptyState' + +const PAGE_SIZE = 20 + +// AlertStatus: Success=1, Failed=2, Pending=3 +const STATUS_META: Record = { + 1: { label: 'موفق', variant: 'success' }, + 2: { label: 'ناموفق', variant: 'error' }, + 3: { label: 'در صف', variant: 'warning' }, +} + +// AlertNotificationType: Call=-1, SMS=0, Relay1..4=1..4 +function notificationLabel(type: number): { label: string; icon: typeof MessageSquare } { + if (type === -1) return { label: 'تماس صوتی', icon: Phone } + if (type === 0) return { label: 'پیامک', icon: MessageSquare } + if (type >= 1 && type <= 4) return { label: `رله ${toPersianDigits(type)}`, icon: Bell } + return { label: 'نامشخص', icon: Bell } +} + +function daysAgoIso(n: number): string { + const d = new Date() + d.setDate(d.getDate() - n) + return d.toISOString().slice(0, 10) +} + +export default function AdminAlertsPage() { + const [result, setResult] = useState | null>(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [page, setPage] = useState(1) + const [onlyErrors, setOnlyErrors] = useState(false) + const [from, setFrom] = useState('') + const [to, setTo] = useState('') + const [deviceId, setDeviceId] = useState('') + + const fetchAlerts = useCallback(async (p: number, errorsOnly: boolean, f: string, t: string, devId: string) => { + setLoading(true) + setError('') + try { + const data = await api.adminAlerts({ + status: errorsOnly ? 2 : undefined, + from: f || undefined, + to: t ? `${t}T23:59:59` : undefined, + deviceId: devId ? Number(devId) : undefined, + page: p, + pageSize: PAGE_SIZE, + }) + setResult(data) + } catch (e) { + setError((e as Error).message || 'خطا در دریافت هشدارها') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchAlerts(page, onlyErrors, from, to, deviceId) + }, [page, onlyErrors, from, to, deviceId, fetchAlerts]) + + const onDateChange = (setter: (v: string) => void) => (e: ChangeEvent) => { + setter(e.target.value) + setPage(1) + } + + const applyLast7Days = () => { + setFrom(daysAgoIso(6)) + setTo(new Date().toISOString().slice(0, 10)) + setPage(1) + } + + const clearFilters = () => { + setFrom('') + setTo('') + setDeviceId('') + setOnlyErrors(false) + setPage(1) + } + + const totalPages = result ? Math.max(1, Math.ceil(result.totalCount / PAGE_SIZE)) : 1 + const showPagination = totalPages > 1 + + return ( +
+
+

فید هشدارها

+ {result && ( + + {toPersianDigits(result.totalCount)} هشدار + + )} +
+ +
+ + + {(from || to || deviceId || onlyErrors) && ( + + )} +
+ { setDeviceId(e.target.value); setPage(1) }} + placeholder="شناسه دستگاه" + className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20" + /> + + تا + +
+
+ + {error &&

{error}

} + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+ ))} +
+ ) : result && result.items.length > 0 ? ( + <> +
+ + + + + + + + + + + + + {result.items.map((alert) => { + const status = STATUS_META[alert.status] ?? STATUS_META[3] + const notif = notificationLabel(alert.notificationType) + const NotifIcon = notif.icon + return ( + + + + + + + + + ) + })} + +
دستگاهمالکنوع اعلانپیاموضعیتزمان ارسال
+ {alert.deviceName} + ({toPersianDigits(alert.deviceId)}) + {alert.ownerName} + {notif.label} + +

{alert.message}

+ {alert.status === 2 && alert.errorMessage && ( +

{alert.errorMessage}

+ )} +
+ {status.label} + + {formatPersianDateFromIso(alert.sentAt)} +
+
+ {showPagination && ( + + )} + + ) : ( + + )} +
+ ) +} diff --git a/src/GreenHome.Web/src/app/admin/audit/page.tsx b/src/GreenHome.Web/src/app/admin/audit/page.tsx new file mode 100644 index 0000000..c063cec --- /dev/null +++ b/src/GreenHome.Web/src/app/admin/audit/page.tsx @@ -0,0 +1,110 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { ScrollText } from 'lucide-react' +import { api, PagedResult, AdminAuditLogItem } from '@/lib/api' +import { toPersianDigits } from '@/lib/format/persian-digits' +import { formatPersianDateFromIso } from '@/lib/date/persian-date' +import { Pagination } from '@/components/navigation/Pagination' +import { Badge } from '@/components/common/Badge' +import { EmptyState } from '@/components/common/EmptyState' + +const PAGE_SIZE = 20 + +export default function AdminAuditPage() { + const [result, setResult] = useState | null>(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [page, setPage] = useState(1) + + const fetchLogs = useCallback(async (p: number) => { + setLoading(true) + setError('') + try { + const data = await api.adminAuditLogs({ page: p, pageSize: PAGE_SIZE }) + setResult(data) + } catch (e) { + setError((e as Error).message || 'خطا در دریافت لاگ مدیریت') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchLogs(page) + }, [page, fetchLogs]) + + const totalPages = result ? Math.max(1, Math.ceil(result.totalCount / PAGE_SIZE)) : 1 + const showPagination = totalPages > 1 + + return ( +
+
+

لاگ مدیریت

+ {result && ( + + {toPersianDigits(result.totalCount)} عملیات + + )} +
+ + {error &&

{error}

} + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+ ))} +
+ ) : result && result.items.length > 0 ? ( + <> +
+ + + + + + + + + + + + {result.items.map((log) => ( + + + + + + + + ))} + +
زمانمدیرعملیاتهدفجزئیات
+ {formatPersianDateFromIso(log.at)} + {log.adminName} + + {log.action} + + + {toPersianDigits(log.targetId)} + + {log.details || '—'} +
+
+ {showPagination && ( + + )} + + ) : ( + + )} +
+ ) +} diff --git a/src/GreenHome.Web/src/app/admin/devices/page.tsx b/src/GreenHome.Web/src/app/admin/devices/page.tsx index c26add3..c5bf6c2 100644 --- a/src/GreenHome.Web/src/app/admin/devices/page.tsx +++ b/src/GreenHome.Web/src/app/admin/devices/page.tsx @@ -1,8 +1,8 @@ 'use client' import { useCallback, useEffect, useState } from 'react' -import { Cpu, Thermometer, Eye } from 'lucide-react' -import { api, PagedResult, AdminDeviceListItem, AdminDeviceDetail } from '@/lib/api' +import { Cpu, Thermometer, Eye, MessageSquare, PhoneCall } from 'lucide-react' +import { api, PagedResult, AdminDeviceListItem, AdminDeviceDetail, AdminDeviceTestResult } from '@/lib/api' import { toPersianDigits } from '@/lib/format/persian-digits' import { formatPersianDateFromIso } from '@/lib/date/persian-date' import { SearchInput } from '@/components/forms/SearchInput' @@ -22,6 +22,8 @@ export default function AdminDevicesPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [detail, setDetail] = useState(null) + const [testSms, setTestSms] = useState<{ busy: boolean; result: AdminDeviceTestResult | null }>({ busy: false, result: null }) + const [testVoice, setTestVoice] = useState<{ busy: boolean; result: AdminDeviceTestResult | null }>({ busy: false, result: null }) const fetchDevices = useCallback(async (p: number, s: string) => { setLoading(true) @@ -49,12 +51,26 @@ export default function AdminDevicesPage() { const openDetail = async (id: number) => { try { - setDetail(await api.adminDevice(id)) + const d = await api.adminDevice(id) + setDetail(d) + setTestSms({ busy: false, result: null }) + setTestVoice({ busy: false, result: null }) } catch (e) { setError((e as Error).message || 'خطا در دریافت جزئیات دستگاه') } } + const runTest = async (kind: 'sms' | 'voice', deviceId: number, setter: typeof setTestSms) => { + setter({ busy: true, result: null }) + try { + const fn = kind === 'sms' ? api.adminTestSms : api.adminTestVoice + const res = await fn(deviceId) + setter({ busy: false, result: res }) + } catch (e) { + setter({ busy: false, result: { success: false, message: (e as Error).message || 'خطای ارتباطی' } }) + } + } + const totalPages = result ? Math.max(1, Math.ceil(result.totalCount / PAGE_SIZE)) : 1 const showPagination = totalPages > 1 @@ -191,6 +207,36 @@ export default function AdminDevicesPage() { )}
+
+

تست اعلان

+
+ + +
+ {(testSms.result || testVoice.result) && ( +
+ {testSms.result && ( +

+ {testSms.result.success ? '✓ ' : '✗ '}پیامک: {testSms.result.message} +

+ )} + {testVoice.result && ( +

+ {testVoice.result.success ? '✓ ' : '✗ '}تماس صوتی: {testVoice.result.message} +

+ )} +
+ )} +
+

diff --git a/src/GreenHome.Web/src/app/admin/posts/page.tsx b/src/GreenHome.Web/src/app/admin/posts/page.tsx new file mode 100644 index 0000000..4d8e6fe --- /dev/null +++ b/src/GreenHome.Web/src/app/admin/posts/page.tsx @@ -0,0 +1,165 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { MessageSquare, Image as ImageIcon, Mic, Camera, Trash2 } from 'lucide-react' +import { api, PagedResult, AdminPostItem } from '@/lib/api' +import { toPersianDigits } from '@/lib/format/persian-digits' +import { formatPersianDateFromIso } from '@/lib/date/persian-date' +import { Pagination } from '@/components/navigation/Pagination' +import { Badge } from '@/components/common/Badge' +import { Button } from '@/components/common/Button' +import { EmptyState } from '@/components/common/EmptyState' +import { useConfirmDialog } from '@/components/utils/ConfirmDialog' + +const PAGE_SIZE = 20 + +// DevicePostType: Text=0, Image=1, Voice=2, SensorSnapshot=3 +const POST_TYPE_META: Record = { + 0: { label: 'متن', icon: MessageSquare }, + 1: { label: 'تصویر', icon: ImageIcon }, + 2: { label: 'ویس', icon: Mic }, + 3: { label: 'اسنپ‌شات', icon: Camera }, +} + +export default function AdminPostsPage() { + const [result, setResult] = useState | null>(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [page, setPage] = useState(1) + const [busyId, setBusyId] = useState(null) + const { confirm, ConfirmDialog } = useConfirmDialog() + + const fetchPosts = useCallback(async (p: number) => { + setLoading(true) + setError('') + try { + const data = await api.adminPosts({ page: p, pageSize: PAGE_SIZE }) + setResult(data) + } catch (e) { + setError((e as Error).message || 'خطا در دریافت پست‌ها') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchPosts(page) + }, [page, fetchPosts]) + + const handleDelete = async (post: AdminPostItem) => { + const ok = await confirm({ + title: 'حذف پست', + message: `این پست به‌طور کامل حذف می‌شود (با تصاویر آن). ادامه می‌دهید؟`, + confirmText: 'حذف', + variant: 'danger', + }) + if (!ok) return + + setBusyId(post.id) + setError('') + try { + await api.adminDeletePost(post.id) + // if the current page becomes empty, step back a page + if (result && result.items.length === 1 && page > 1) { + setPage(page - 1) + } else { + fetchPosts(page) + } + } catch (e) { + setError((e as Error).message || 'خطا در حذف پست') + } finally { + setBusyId(null) + } + } + + const totalPages = result ? Math.max(1, Math.ceil(result.totalCount / PAGE_SIZE)) : 1 + const showPagination = totalPages > 1 + + return ( +
+
+

پست‌ها

+ {result && ( + + {toPersianDigits(result.totalCount)} پست + + )} +
+ + {error &&

{error}

} + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+ ))} +
+ ) : result && result.items.length > 0 ? ( + <> +
+ + + + + + + + + + + + + {result.items.map((post) => { + const meta = POST_TYPE_META[post.postType] ?? POST_TYPE_META[0] + const TypeIcon = meta.icon + return ( + + + + + + + + + ) + })} + +
دستگاهنویسندهنوعمحتواتاریخعملیات
+ {post.deviceName} + ({toPersianDigits(post.deviceId)}) + {post.authorName} + {meta.label} + {post.imageCount > 0 && ( + + {toPersianDigits(post.imageCount)} تصویر + + )} + +

{post.content || '—'}

+
+ {formatPersianDateFromIso(post.createdAt)} + + +
+
+ {showPagination && ( + + )} + + ) : ( + + )} + + +
+ ) +} diff --git a/src/GreenHome.Web/src/components/utils/ConfirmDialog.tsx b/src/GreenHome.Web/src/components/utils/ConfirmDialog.tsx index 2f2aad2..4450b33 100644 --- a/src/GreenHome.Web/src/components/utils/ConfirmDialog.tsx +++ b/src/GreenHome.Web/src/components/utils/ConfirmDialog.tsx @@ -14,6 +14,7 @@ type ConfirmDialogOptions = { type ConfirmDialogHook = { confirm: (options: ConfirmDialogOptions) => Promise + ConfirmDialog: React.ComponentType } export function useConfirmDialog(): ConfirmDialogHook { @@ -71,7 +72,7 @@ export function useConfirmDialog(): ConfirmDialogHook { return { confirm, ConfirmDialog: ConfirmDialogComponent - } as ConfirmDialogHook & { ConfirmDialog: React.ComponentType } + } } // Simple confirm function for direct use (fallback to window.confirm) diff --git a/src/GreenHome.Web/src/lib/api/client.ts b/src/GreenHome.Web/src/lib/api/client.ts index b4fdd80..76cf200 100644 --- a/src/GreenHome.Web/src/lib/api/client.ts +++ b/src/GreenHome.Web/src/lib/api/client.ts @@ -24,6 +24,10 @@ import type { AdminDeviceListItem, AdminDeviceDetail, AdminUsageDay, + AdminAlertItem, + AdminPostItem, + AdminDeviceTestResult, + AdminAuditLogItem, } from './types' const API_BASE = import.meta.env.VITE_API_URL ?? '' @@ -239,4 +243,34 @@ export const api = { if (q.userId) params.set('userId', String(q.userId)) return http(`${API_BASE}/api/admin/usage?${params.toString()}`) }, + adminAlerts: (q: { deviceId?: number; status?: number; from?: string; to?: string; page?: number; pageSize?: number }) => { + const params = new URLSearchParams() + if (q.deviceId) params.set('deviceId', String(q.deviceId)) + if (q.status) params.set('status', String(q.status)) + if (q.from) params.set('from', q.from) + if (q.to) params.set('to', q.to) + if (q.page) params.set('page', String(q.page)) + if (q.pageSize) params.set('pageSize', String(q.pageSize)) + return http>(`${API_BASE}/api/admin/alerts?${params.toString()}`) + }, + adminPosts: (q: { deviceId?: number; page?: number; pageSize?: number }) => { + const params = new URLSearchParams() + if (q.deviceId) params.set('deviceId', String(q.deviceId)) + if (q.page) params.set('page', String(q.page)) + if (q.pageSize) params.set('pageSize', String(q.pageSize)) + return http>(`${API_BASE}/api/admin/posts?${params.toString()}`) + }, + adminDeletePost: (id: number) => + http(`${API_BASE}/api/admin/posts/${id}`, { method: 'DELETE' }), + adminTestSms: (deviceId: number) => + http(`${API_BASE}/api/admin/devices/${deviceId}/test-sms`, { method: 'POST' }), + adminTestVoice: (deviceId: number) => + http(`${API_BASE}/api/admin/devices/${deviceId}/test-voice`, { method: 'POST' }), + adminAuditLogs: (q: { adminId?: number; page?: number; pageSize?: number }) => { + const params = new URLSearchParams() + if (q.adminId) params.set('adminId', String(q.adminId)) + if (q.page) params.set('page', String(q.page)) + if (q.pageSize) params.set('pageSize', String(q.pageSize)) + return http>(`${API_BASE}/api/admin/audit-logs?${params.toString()}`) + }, } diff --git a/src/GreenHome.Web/src/lib/api/types.ts b/src/GreenHome.Web/src/lib/api/types.ts index 5b8c9cf..78ef19b 100644 --- a/src/GreenHome.Web/src/lib/api/types.ts +++ b/src/GreenHome.Web/src/lib/api/types.ts @@ -342,3 +342,41 @@ export type AdminUsageDay = { aiTokenCount: number } +export type AdminAlertItem = { + id: number + deviceId: number + deviceName: string + ownerName: string + message: string + notificationType: number + status: number + errorMessage?: string + sentAt: string +} + +export type AdminPostItem = { + id: number + deviceId: number + deviceName: string + authorName: string + content: string + postType: number + imageCount: number + createdAt: string +} + +export type AdminDeviceTestResult = { + success: boolean + message: string +} + +export type AdminAuditLogItem = { + id: number + adminUserId: number + adminName: string + action: string + targetId: string + details: string + at: string +} +