From 9d4ad26dcc407ab0005c1e5feb74a31f7bef700f Mon Sep 17 00:00:00 2001 From: alireza Date: Fri, 25 Sep 2026 15:01:33 +0330 Subject: [PATCH] initialize admin panel and necessry pages --- .../Controllers/AdminController.cs | 126 ++ src/GreenHome.Api/Filters/AdminAuthFilter.cs | 60 + src/GreenHome.Api/Program.cs | 3 + src/GreenHome.Api/appsettings.json | 4 + .../Dtos/Admin/AdminDtos.cs | 129 ++ .../Dtos/Auth/UserDto.cs | 1 + .../Interfaces/IAdminService.cs | 15 + src/GreenHome.Domain/UsageCounter.cs | 21 + src/GreenHome.Domain/User.cs | 1 + .../Data/GreenHomeDbContext.cs | 13 + .../20260925104759_AdminPanel.Designer.cs | 1548 +++++++++++++++++ .../Migrations/20260925104759_AdminPanel.cs | 68 + .../GreenHomeDbContextModelSnapshot.cs | 50 + .../DependencyInjection.cs | 8 + .../Services/AI/AIQueryService.cs | 9 + .../Services/Admin/AdminService.cs | 399 +++++ .../Services/Alert/AlertNotificationSender.cs | 21 +- .../Services/Auth/AuthService.cs | 38 +- .../Services/Device/DeviceTokenService.cs | 7 + .../Services/Usage/UsageTracker.cs | 69 + src/GreenHome.Web/src/App.tsx | 13 +- .../src/app/admin/AdminLayout.tsx | 83 + .../src/app/admin/devices/page.tsx | 231 +++ src/GreenHome.Web/src/app/admin/page.tsx | 114 ++ .../src/app/admin/usage/page.tsx | 170 ++ .../src/app/admin/users/page.tsx | 229 +++ .../src/components/layout/AdminGuard.tsx | 25 + .../src/components/layout/index.ts | 1 + src/GreenHome.Web/src/lib/api/client.ts | 36 + src/GreenHome.Web/src/lib/api/types.ts | 67 + .../src/lib/date/persian-date.ts | 13 + 31 files changed, 3565 insertions(+), 7 deletions(-) create mode 100644 src/GreenHome.Api/Controllers/AdminController.cs create mode 100644 src/GreenHome.Api/Filters/AdminAuthFilter.cs create mode 100644 src/GreenHome.Application/Dtos/Admin/AdminDtos.cs create mode 100644 src/GreenHome.Application/Interfaces/IAdminService.cs create mode 100644 src/GreenHome.Domain/UsageCounter.cs create mode 100644 src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925104759_AdminPanel.Designer.cs create mode 100644 src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925104759_AdminPanel.cs create mode 100644 src/GreenHome.Infrastructure/Services/Admin/AdminService.cs create mode 100644 src/GreenHome.Infrastructure/Services/Usage/UsageTracker.cs create mode 100644 src/GreenHome.Web/src/app/admin/AdminLayout.tsx create mode 100644 src/GreenHome.Web/src/app/admin/devices/page.tsx create mode 100644 src/GreenHome.Web/src/app/admin/page.tsx create mode 100644 src/GreenHome.Web/src/app/admin/usage/page.tsx create mode 100644 src/GreenHome.Web/src/app/admin/users/page.tsx create mode 100644 src/GreenHome.Web/src/components/layout/AdminGuard.tsx diff --git a/src/GreenHome.Api/Controllers/AdminController.cs b/src/GreenHome.Api/Controllers/AdminController.cs new file mode 100644 index 0000000..ed8a370 --- /dev/null +++ b/src/GreenHome.Api/Controllers/AdminController.cs @@ -0,0 +1,126 @@ +using GreenHome.Api.Filters; +using GreenHome.Application; +using GreenHome.Application.Dtos.Admin; +using GreenHome.Domain; +using Microsoft.AspNetCore.Mvc; + +namespace GreenHome.Api.Controllers; + +[ApiController] +[Route("api/admin")] +[ServiceFilter(typeof(AdminAuthFilter))] +public sealed class AdminController : ControllerBase +{ + private readonly IAdminService _adminService; + + public AdminController(IAdminService adminService) + { + _adminService = adminService; + } + + // ==================== Dashboard ==================== + + [HttpGet("dashboard")] + public async Task> Dashboard(CancellationToken cancellationToken) + { + var result = await _adminService.GetDashboardAsync(cancellationToken); + return Ok(result); + } + + // ==================== Users ==================== + + [HttpGet("users")] + public async Task>> Users( + [FromQuery] string? search, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + var filter = new AdminUserFilter + { + Search = search, + Page = Math.Max(1, page), + PageSize = Math.Clamp(pageSize, 1, 100) + }; + + var result = await _adminService.GetUsersAsync(filter, cancellationToken); + return Ok(result); + } + + [HttpGet("users/{id:int}")] + public async Task> GetUser(int id, CancellationToken cancellationToken) + { + var result = await _adminService.GetUserAsync(id, cancellationToken); + return result is null ? NotFound() : Ok(result); + } + + [HttpPut("users/{id:int}/role")] + public async Task SetUserRole(int id, [FromBody] ChangeUserRoleRequest request, CancellationToken cancellationToken) + { + var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id; + var outcome = await _adminService.SetUserRoleAsync(id, request.Role, currentAdminId, cancellationToken); + return outcome switch + { + UserMutationOutcome.Ok => Ok(new { success = true }), + UserMutationOutcome.NotFound => NotFound(), + UserMutationOutcome.ConflictSelf => Conflict(new { message = "نمی‌توانید نقش خود را تغییر دهید" }), + UserMutationOutcome.ConflictLastAdmin => Conflict(new { message = "آخرین مدیر را نمی‌توان تغییر نقش داد" }), + _ => NotFound() + }; + } + + [HttpPut("users/{id:int}/status")] + public async Task SetUserStatus(int id, [FromBody] ChangeUserStatusRequest request, CancellationToken cancellationToken) + { + var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id; + var outcome = await _adminService.SetUserStatusAsync(id, request.IsEnabled, currentAdminId, cancellationToken); + return outcome switch + { + UserMutationOutcome.Ok => Ok(new { success = true }), + UserMutationOutcome.NotFound => NotFound(), + UserMutationOutcome.ConflictSelf => Conflict(new { message = "نمی‌توانید اکانت خود را غیرفعال کنید" }), + UserMutationOutcome.ConflictLastAdmin => Conflict(new { message = "آخرین مدیر فعال را نمی‌توان غیرفعال کرد" }), + _ => NotFound() + }; + } + + // ==================== Devices ==================== + + [HttpGet("devices")] + public async Task>> Devices( + [FromQuery] string? search, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + var filter = new AdminDeviceFilter + { + Search = search, + Page = Math.Max(1, page), + PageSize = Math.Clamp(pageSize, 1, 100) + }; + + var result = await _adminService.GetDevicesAsync(filter, cancellationToken); + return Ok(result); + } + + [HttpGet("devices/{id:int}")] + public async Task> GetDevice(int id, CancellationToken cancellationToken) + { + var result = await _adminService.GetDeviceAsync(id, cancellationToken); + return result is null ? NotFound() : Ok(result); + } + + // ==================== Usage ==================== + + [HttpGet("usage")] + public async Task>> Usage( + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] int? userId, + CancellationToken cancellationToken = default) + { + var result = await _adminService.GetUsageAsync(from, to, userId, cancellationToken); + return Ok(result); + } +} diff --git a/src/GreenHome.Api/Filters/AdminAuthFilter.cs b/src/GreenHome.Api/Filters/AdminAuthFilter.cs new file mode 100644 index 0000000..28d91b5 --- /dev/null +++ b/src/GreenHome.Api/Filters/AdminAuthFilter.cs @@ -0,0 +1,60 @@ +using GreenHome.Application; +using GreenHome.Domain; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace GreenHome.Api.Filters; + +/// +/// Guards all api/admin/* endpoints: +/// - missing/invalid/expired token -> 401 +/// - valid token but Role != Admin -> 403 +/// Role is always read from the server-side user lookup (IAuthService.GetUserByTokenAsync +/// does a DB lookup), never from client-provided claims. +/// +public sealed class AdminAuthFilter : IAsyncActionFilter +{ + private readonly IAuthService _authService; + + public AdminAuthFilter(IAuthService authService) + { + _authService = authService; + } + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var header = context.HttpContext.Request.Headers.Authorization.ToString(); + var token = header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? header["Bearer ".Length..].Trim() + : null; + + if (string.IsNullOrWhiteSpace(token)) + { + context.Result = new UnauthorizedObjectResult(new { message = "Token ارسال نشده است" }); + return; + } + + var user = await _authService.GetUserByTokenAsync(token, context.HttpContext.RequestAborted); + + if (user == null) + { + // invalid/expired token OR disabled user + context.Result = new UnauthorizedObjectResult(new { message = "Token نامعتبر است" }); + return; + } + + if (user.Role != UserRole.Admin) + { + context.Result = new ObjectResult(new { message = "دسترسی شما به این بخش مجاز نیست" }) + { + StatusCode = StatusCodes.Status403Forbidden + }; + return; + } + + // expose the authenticated admin to the controller without a second DB round-trip + context.HttpContext.Items["AdminUser"] = user; + + await next(); + } +} diff --git a/src/GreenHome.Api/Program.cs b/src/GreenHome.Api/Program.cs index 62e2ebe..c8c57d0 100644 --- a/src/GreenHome.Api/Program.cs +++ b/src/GreenHome.Api/Program.cs @@ -53,6 +53,9 @@ void ConfigureCoreServices(WebApplicationBuilder builder) builder.Services.AddAutoMapper(typeof(GreenHome.Application.MappingProfile)); builder.Services.AddValidatorsFromAssemblyContaining(); + // Admin panel auth filter (used via [ServiceFilter] on AdminController) + builder.Services.AddScoped(); + builder.Services.AddInfrastructure(builder.Configuration); } diff --git a/src/GreenHome.Api/appsettings.json b/src/GreenHome.Api/appsettings.json index aeb5ea8..658c483 100644 --- a/src/GreenHome.Api/appsettings.json +++ b/src/GreenHome.Api/appsettings.json @@ -22,5 +22,9 @@ "TokenSecret": "CHANGE_ME_USE_A_STRONG_RANDOM_SECRET_AT_LEAST_32_CHARS" }, + "Admin": { + "OnlineWindowMinutes": 15 + }, + "AllowedHosts": "*" } \ No newline at end of file diff --git a/src/GreenHome.Application/Dtos/Admin/AdminDtos.cs b/src/GreenHome.Application/Dtos/Admin/AdminDtos.cs new file mode 100644 index 0000000..6054ccd --- /dev/null +++ b/src/GreenHome.Application/Dtos/Admin/AdminDtos.cs @@ -0,0 +1,129 @@ +using Domain = GreenHome.Domain; + +namespace GreenHome.Application.Dtos.Admin; + +// ==================== Dashboard ==================== + +public sealed class AdminDashboardDto +{ + public int TotalUsers { get; set; } + public int TotalDevices { get; set; } + public int OnlineDevices { get; set; } + public int OfflineDevices { get; set; } + public int AlertsLast24h { get; set; } + public int SmsLast7Days { get; set; } + public int AiQueriesLast7Days { get; set; } + + /// تعداد SMS بر اساس روز برای ۷ روز اخیر (sparkline) + public IReadOnlyList SmsDailyLast7Days { get; set; } = []; +} + +// ==================== Users ==================== + +public sealed class AdminUserFilter +{ + public string? Search { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 20; +} + +public sealed class AdminUserListItemDto +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Family { get; set; } = string.Empty; + public string Mobile { get; set; } = string.Empty; + public Domain.UserRole Role { get; set; } + public bool IsDisabled { get; set; } + public int DeviceCount { get; set; } + public DateTime? LastLoginAt { get; set; } + public DateTime CreatedAt { get; set; } +} + +public sealed class AdminUserDetailDto +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Family { get; set; } = string.Empty; + public string Mobile { get; set; } = string.Empty; + public Domain.UserRole Role { get; set; } + public bool IsDisabled { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? LastLoginAt { get; set; } + public IReadOnlyList Devices { get; set; } = []; +} + +public sealed class ChangeUserRoleRequest +{ + public Domain.UserRole Role { get; set; } +} + +public sealed class ChangeUserStatusRequest +{ + public bool IsEnabled { get; set; } +} + +/// +/// نتیجه تغییر نقش/وضعیت کاربر توسط admin. +/// +public enum UserMutationOutcome +{ + Ok, + NotFound, + ConflictSelf, + ConflictLastAdmin +} + +// ==================== Devices ==================== + +public sealed class AdminDeviceFilter +{ + public string? Search { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 20; +} + +public sealed class AdminDeviceListItemDto +{ + public int Id { get; set; } + public string DeviceName { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public string OwnerName { get; set; } = string.Empty; + public string OwnerMobile { get; set; } = string.Empty; + public bool IsOnline { get; set; } + public DateTime? LastTelemetryAt { get; set; } + public decimal? TemperatureC { get; set; } + public decimal? HumidityPercent { get; set; } +} + +public sealed class AdminDeviceDetailDto +{ + public int Id { get; set; } + public string DeviceName { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public string OwnerName { get; set; } = string.Empty; + public string OwnerFamily { get; set; } = string.Empty; + public string OwnerMobile { get; set; } = string.Empty; + public bool IsOnline { get; set; } + public int MemberCount { get; set; } + + public string? ProductType { get; set; } + public string? Province { get; set; } + public string? City { get; set; } + public string? DevicePhoneNumber { get; set; } + public int? UploadIntervalMin { get; set; } + public int? RelayCount { get; set; } + + public IReadOnlyList LastTelemetry { get; set; } = []; +} + +// ==================== Usage ==================== + +public sealed class UsageDayDto +{ + public DateTime Date { get; set; } + public int SmsCount { get; set; } + public int VoiceCallCount { get; set; } + public int AiQueryCount { get; set; } + public int AiTokenCount { get; set; } +} diff --git a/src/GreenHome.Application/Dtos/Auth/UserDto.cs b/src/GreenHome.Application/Dtos/Auth/UserDto.cs index bc7c529..b5e4a14 100644 --- a/src/GreenHome.Application/Dtos/Auth/UserDto.cs +++ b/src/GreenHome.Application/Dtos/Auth/UserDto.cs @@ -9,4 +9,5 @@ public sealed class UserDto public string Name { get; set; } = string.Empty; public string Family { get; set; } = string.Empty; public Domain.UserRole Role { get; set; } + public bool IsDisabled { get; set; } } diff --git a/src/GreenHome.Application/Interfaces/IAdminService.cs b/src/GreenHome.Application/Interfaces/IAdminService.cs new file mode 100644 index 0000000..b85d4b4 --- /dev/null +++ b/src/GreenHome.Application/Interfaces/IAdminService.cs @@ -0,0 +1,15 @@ +using GreenHome.Application.Dtos.Admin; + +namespace GreenHome.Application; + +public interface IAdminService +{ + Task GetDashboardAsync(CancellationToken cancellationToken); + Task> GetUsersAsync(AdminUserFilter filter, CancellationToken cancellationToken); + Task GetUserAsync(int id, CancellationToken cancellationToken); + Task SetUserRoleAsync(int id, Domain.UserRole role, int currentAdminId, CancellationToken cancellationToken); + Task SetUserStatusAsync(int id, bool isEnabled, int currentAdminId, CancellationToken cancellationToken); + Task> GetDevicesAsync(AdminDeviceFilter filter, CancellationToken cancellationToken); + Task GetDeviceAsync(int id, CancellationToken cancellationToken); + Task> GetUsageAsync(DateTime? from, DateTime? to, int? userId, CancellationToken cancellationToken); +} diff --git a/src/GreenHome.Domain/UsageCounter.cs b/src/GreenHome.Domain/UsageCounter.cs new file mode 100644 index 0000000..c029ebb --- /dev/null +++ b/src/GreenHome.Domain/UsageCounter.cs @@ -0,0 +1,21 @@ +namespace GreenHome.Domain; + +/// +/// مصرف هر کاربر در هر روز (تعداد SMS، تماس صوتی، query AI و توکن AI). +/// +public sealed class UsageCounter +{ + public int Id { get; set; } + public int UserId { get; set; } + public User User { get; set; } = null!; + + /// + /// تاریخ (فقط date). روز از DateTime.UtcNow مشتق می‌شود تا با Timestampهای Utc هم‌خوان باشد. + /// + public DateOnly Date { get; set; } + + public int SmsCount { get; set; } + public int VoiceCallCount { get; set; } + public int AiQueryCount { get; set; } + public int AiTokenCount { get; set; } +} diff --git a/src/GreenHome.Domain/User.cs b/src/GreenHome.Domain/User.cs index 3ad5bf1..ecfdcce 100644 --- a/src/GreenHome.Domain/User.cs +++ b/src/GreenHome.Domain/User.cs @@ -7,6 +7,7 @@ public sealed class User public string Name { get; set; } = string.Empty; public string Family { get; set; } = string.Empty; public UserRole Role { get; set; } = UserRole.Normal; + public bool IsDisabled { get; set; } = false; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? LastLoginAt { get; set; } public ICollection DeviceUsers { get; set; } = new List(); diff --git a/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs b/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs index f70c3af..cf258a0 100644 --- a/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs +++ b/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs @@ -30,6 +30,7 @@ public sealed class GreenHomeDbContext : DbContext public DbSet DevicePosts => Set(); public DbSet DevicePostImages => Set(); public DbSet PowerOutageLogs => Set(); + public DbSet UsageCounters => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -445,5 +446,17 @@ public sealed class GreenHomeDbContext : DbContext b.HasIndex(x => new { x.DeviceId, x.PersianYear, x.PersianMonth }); b.HasIndex(x => x.EventType); }); + + modelBuilder.Entity(b => + { + b.ToTable("UsageCounters"); + b.HasKey(x => x.Id); + b.HasOne(x => x.User) + .WithMany() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + b.HasIndex(x => new { x.UserId, x.Date }).IsUnique(); + b.HasIndex(x => x.Date); + }); } } diff --git a/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925104759_AdminPanel.Designer.cs b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925104759_AdminPanel.Designer.cs new file mode 100644 index 0000000..6051f72 --- /dev/null +++ b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925104759_AdminPanel.Designer.cs @@ -0,0 +1,1548 @@ +// +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("20260925104759_AdminPanel")] + partial class AdminPanel + { + /// + 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("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.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/20260925104759_AdminPanel.cs b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925104759_AdminPanel.cs new file mode 100644 index 0000000..9aa9415 --- /dev/null +++ b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925104759_AdminPanel.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GreenHome.Infrastructure.Data.Migrations.Migrations +{ + /// + public partial class AdminPanel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsDisabled", + table: "Users", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "UsageCounters", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "int", nullable: false), + Date = table.Column(type: "date", nullable: false), + SmsCount = table.Column(type: "int", nullable: false), + VoiceCallCount = table.Column(type: "int", nullable: false), + AiQueryCount = table.Column(type: "int", nullable: false), + AiTokenCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UsageCounters", x => x.Id); + table.ForeignKey( + name: "FK_UsageCounters_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UsageCounters_Date", + table: "UsageCounters", + column: "Date"); + + migrationBuilder.CreateIndex( + name: "IX_UsageCounters_UserId_Date", + table: "UsageCounters", + columns: new[] { "UserId", "Date" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UsageCounters"); + + migrationBuilder.DropColumn( + name: "IsDisabled", + table: "Users"); + } + } +} diff --git a/src/GreenHome.Infrastructure/Data/Migrations/Migrations/GreenHomeDbContextModelSnapshot.cs b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/GreenHomeDbContextModelSnapshot.cs index c31376d..541cd0e 100644 --- a/src/GreenHome.Infrastructure/Data/Migrations/Migrations/GreenHomeDbContextModelSnapshot.cs +++ b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/GreenHomeDbContextModelSnapshot.cs @@ -978,6 +978,42 @@ namespace GreenHome.Infrastructure.Data.Migrations 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") @@ -994,6 +1030,9 @@ namespace GreenHome.Infrastructure.Data.Migrations .HasMaxLength(100) .HasColumnType("nvarchar(100)"); + b.Property("IsDisabled") + .HasColumnType("bit"); + b.Property("LastLoginAt") .HasColumnType("datetime2"); @@ -1417,6 +1456,17 @@ namespace GreenHome.Infrastructure.Data.Migrations 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") diff --git a/src/GreenHome.Infrastructure/DependencyInjection.cs b/src/GreenHome.Infrastructure/DependencyInjection.cs index ee774f0..a0fef08 100644 --- a/src/GreenHome.Infrastructure/DependencyInjection.cs +++ b/src/GreenHome.Infrastructure/DependencyInjection.cs @@ -1,6 +1,7 @@ using GreenHome.Application; using GreenHome.Infrastructure.Data; using GreenHome.Infrastructure.Services.AI; +using GreenHome.Infrastructure.Services.Admin; using GreenHome.Infrastructure.Services.Alert; using GreenHome.Infrastructure.Services.Auth; using GreenHome.Infrastructure.Services.Checklist; @@ -9,6 +10,7 @@ using GreenHome.Infrastructure.Services.PowerOutage; using GreenHome.Infrastructure.Services.Report; using GreenHome.Infrastructure.Services.Sun; using GreenHome.Infrastructure.Services.Telemetry; +using GreenHome.Infrastructure.Services.Usage; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -34,6 +36,12 @@ public static class DependencyInjection // Auth services.AddScoped(); + // Usage tracking + services.AddScoped(); + + // Admin + services.AddScoped(); + // Alert services.AddScoped(); services.AddScoped(); diff --git a/src/GreenHome.Infrastructure/Services/AI/AIQueryService.cs b/src/GreenHome.Infrastructure/Services/AI/AIQueryService.cs index 68817f5..bac2a8b 100644 --- a/src/GreenHome.Infrastructure/Services/AI/AIQueryService.cs +++ b/src/GreenHome.Infrastructure/Services/AI/AIQueryService.cs @@ -1,6 +1,7 @@ using GreenHome.Application; using GreenHome.Domain; using GreenHome.Infrastructure.Data; +using GreenHome.Infrastructure.Services.Usage; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -9,13 +10,16 @@ namespace GreenHome.Infrastructure.Services.AI; public sealed class AIQueryService : IAIQueryService { private readonly GreenHomeDbContext _dbContext; + private readonly UsageTracker _usageTracker; private readonly ILogger _logger; public AIQueryService( GreenHomeDbContext dbContext, + UsageTracker usageTracker, ILogger logger) { _dbContext = dbContext; + _usageTracker = usageTracker; _logger = logger; } @@ -26,6 +30,11 @@ public sealed class AIQueryService : IAIQueryService _dbContext.AIQueries.Add(query); await _dbContext.SaveChangesAsync(cancellationToken); + if (query.UserId.HasValue) + { + await _usageTracker.RecordAsync(query.UserId.Value, UsageKind.AiQuery, tokens: query.TotalTokens, cancellationToken: cancellationToken); + } + _logger.LogInformation("AI query saved: {QueryId}, Tokens: {TotalTokens}", query.Id, query.TotalTokens); diff --git a/src/GreenHome.Infrastructure/Services/Admin/AdminService.cs b/src/GreenHome.Infrastructure/Services/Admin/AdminService.cs new file mode 100644 index 0000000..6790d4c --- /dev/null +++ b/src/GreenHome.Infrastructure/Services/Admin/AdminService.cs @@ -0,0 +1,399 @@ +using AutoMapper; +using GreenHome.Application; +using GreenHome.Application.Dtos.Admin; +using GreenHome.Domain; +using GreenHome.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace GreenHome.Infrastructure.Services.Admin; + +public sealed class AdminService : IAdminService +{ + private readonly GreenHomeDbContext _dbContext; + private readonly IMapper _mapper; + private readonly int _onlineWindowMinutes; + + public AdminService(GreenHomeDbContext dbContext, IMapper mapper, IConfiguration configuration) + { + _dbContext = dbContext; + _mapper = mapper; + _onlineWindowMinutes = configuration.GetValue("Admin:OnlineWindowMinutes", 15); + } + + // ==================== Dashboard ==================== + + public async Task GetDashboardAsync(CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var onlineCutoff = now.AddMinutes(-_onlineWindowMinutes); + var dayAgo = now.AddDays(-1); + var weekAgo = now.AddDays(-7); + + var totalUsers = await _dbContext.Users.CountAsync(cancellationToken); + var totalDevices = await _dbContext.Devices.CountAsync(cancellationToken); + var onlineDevices = await _dbContext.TelemetryRecords + .Where(t => t.ServerTimestampUtc >= onlineCutoff) + .Select(t => t.DeviceId) + .Distinct() + .CountAsync(cancellationToken); + var alertsLast24h = await _dbContext.AlertLogs + .CountAsync(a => a.SentAt >= dayAgo, cancellationToken); + var aiQueriesLast7Days = await _dbContext.AIQueries + .CountAsync(q => q.CreatedAt >= weekAgo, cancellationToken); + + // ponytail: SMS count + sparkline come from UsageCounters (single source of truth, + // matches the usage page); AlertLogs is no longer consulted here. + var weekStart = DateOnly.FromDateTime(now.Date.AddDays(-6)); + var smsCounters = await _dbContext.UsageCounters + .AsNoTracking() + .Where(u => u.Date >= weekStart) + .GroupBy(u => u.Date) + .Select(g => new { Date = g.Key, Count = g.Sum(x => x.SmsCount) }) + .ToListAsync(cancellationToken); + + var smsLast7Days = smsCounters.Sum(x => x.Count); + + var daily = new List(7); + for (var i = 6; i >= 0; i--) + { + var day = DateOnly.FromDateTime(now.Date.AddDays(-i)); + daily.Add(new UsageDayDto + { + Date = day.ToDateTime(new TimeOnly(0, 0)), + SmsCount = smsCounters.FirstOrDefault(x => x.Date == day)?.Count ?? 0 + }); + } + + return new AdminDashboardDto + { + TotalUsers = totalUsers, + TotalDevices = totalDevices, + OnlineDevices = onlineDevices, + OfflineDevices = totalDevices - onlineDevices, + AlertsLast24h = alertsLast24h, + SmsLast7Days = smsLast7Days, + AiQueriesLast7Days = aiQueriesLast7Days, + SmsDailyLast7Days = daily + }; + } + + // ==================== Users ==================== + + public async Task> GetUsersAsync(AdminUserFilter filter, CancellationToken cancellationToken) + { + if (filter.Page <= 0) filter.Page = 1; + if (filter.PageSize <= 0) filter.PageSize = 20; + + IQueryable query = _dbContext.Users.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(filter.Search)) + { + // normalize Persian/Arabic digits so «۰۹...» finds the stored «09...» + var search = NormalizeDigits(filter.Search.Trim()); + query = query.Where(u => u.Name.Contains(search) || u.Family.Contains(search) || u.Mobile.Contains(search)); + } + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query + .OrderByDescending(u => u.LastLoginAt) + .ThenByDescending(u => u.Id) + .Skip((filter.Page - 1) * filter.PageSize) + .Take(filter.PageSize) + .ToListAsync(cancellationToken); + + var ids = items.Select(u => u.Id).ToList(); + var deviceCounts = await _dbContext.Devices + .Where(d => ids.Contains(d.UserId)) + .GroupBy(d => d.UserId) + .Select(g => new { UserId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.UserId, x => x.Count, cancellationToken); + + return new PagedResult + { + Items = items.Select(u => new AdminUserListItemDto + { + Id = u.Id, + Name = u.Name, + Family = u.Family, + Mobile = u.Mobile, + Role = u.Role, + IsDisabled = u.IsDisabled, + DeviceCount = deviceCounts.TryGetValue(u.Id, out var c) ? c : 0, + LastLoginAt = u.LastLoginAt, + CreatedAt = u.CreatedAt + }).ToList(), + TotalCount = totalCount, + Page = filter.Page, + PageSize = filter.PageSize + }; + } + + public async Task GetUserAsync(int id, CancellationToken cancellationToken) + { + var user = await _dbContext.Users.AsNoTracking() + .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + if (user == null) return null; + + var deviceIds = await _dbContext.Devices + .Where(d => d.UserId == id) + .Select(d => d.Id) + .ToListAsync(cancellationToken); + + var (latestMap, _) = await GetDeviceMetaAsync(deviceIds, cancellationToken); + + return new AdminUserDetailDto + { + Id = user.Id, + Name = user.Name, + Family = user.Family, + Mobile = user.Mobile, + Role = user.Role, + IsDisabled = user.IsDisabled, + CreatedAt = user.CreatedAt, + LastLoginAt = user.LastLoginAt, + Devices = await BuildDeviceListAsync(deviceIds, latestMap, cancellationToken) + }; + } + + public async Task SetUserRoleAsync(int id, UserRole role, int currentAdminId, CancellationToken cancellationToken) + { + var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + if (user == null) return UserMutationOutcome.NotFound; + + if (id == currentAdminId) return UserMutationOutcome.ConflictSelf; + + // don't allow demoting/removing the last active admin + if (user.Role == UserRole.Admin && role != UserRole.Admin) + { + var activeAdmins = await _dbContext.Users + .CountAsync(u => u.Role == UserRole.Admin && !u.IsDisabled, cancellationToken); + if (activeAdmins <= 1) return UserMutationOutcome.ConflictLastAdmin; + } + + user.Role = role; + await _dbContext.SaveChangesAsync(cancellationToken); + return UserMutationOutcome.Ok; + } + + public async Task SetUserStatusAsync(int id, bool isEnabled, int currentAdminId, CancellationToken cancellationToken) + { + var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + if (user == null) return UserMutationOutcome.NotFound; + + if (id == currentAdminId) return UserMutationOutcome.ConflictSelf; + + // disabling the last active admin would lock the panel out + if (!isEnabled && user.Role == UserRole.Admin && !user.IsDisabled) + { + var activeAdmins = await _dbContext.Users + .CountAsync(u => u.Role == UserRole.Admin && !u.IsDisabled, cancellationToken); + if (activeAdmins <= 1) return UserMutationOutcome.ConflictLastAdmin; + } + + user.IsDisabled = !isEnabled; + await _dbContext.SaveChangesAsync(cancellationToken); + return UserMutationOutcome.Ok; + } + + // ==================== Devices ==================== + + public async Task> GetDevicesAsync(AdminDeviceFilter filter, CancellationToken cancellationToken) + { + if (filter.Page <= 0) filter.Page = 1; + if (filter.PageSize <= 0) filter.PageSize = 20; + + IQueryable query = _dbContext.Devices.AsNoTracking().Include(d => d.User); + + if (!string.IsNullOrWhiteSpace(filter.Search)) + { + var search = NormalizeDigits(filter.Search.Trim()); + query = query.Where(d => + d.DeviceName.Contains(search) || + d.Location.Contains(search) || + d.User.Name.Contains(search) || + d.User.Family.Contains(search) || + d.User.Mobile.Contains(search)); + } + + var totalCount = await query.CountAsync(cancellationToken); + + var page = await query + .OrderBy(d => d.DeviceName) + .Skip((filter.Page - 1) * filter.PageSize) + .Take(filter.PageSize) + .ToListAsync(cancellationToken); + + var deviceIds = page.Select(d => d.Id).ToList(); + var (latestMap, _) = await GetDeviceMetaAsync(deviceIds, cancellationToken); + + var items = page.Select(d => ToDeviceListItem(d, latestMap)).ToList(); + + return new PagedResult + { + Items = items, + TotalCount = totalCount, + Page = filter.Page, + PageSize = filter.PageSize + }; + } + + public async Task GetDeviceAsync(int id, CancellationToken cancellationToken) + { + var device = await _dbContext.Devices.AsNoTracking() + .Include(d => d.User) + .FirstOrDefaultAsync(d => d.Id == id, cancellationToken); + if (device == null) return null; + + var settings = await _dbContext.DeviceSettings + .AsNoTracking() + .FirstOrDefaultAsync(s => s.DeviceId == id, cancellationToken); + + var memberCount = await _dbContext.DeviceUsers + .CountAsync(du => du.DeviceId == id, cancellationToken); + + var lastTelemetry = await _dbContext.TelemetryRecords + .AsNoTracking() + .Where(t => t.DeviceId == id) + .OrderByDescending(t => t.ServerTimestampUtc) + .Take(10) + .ToListAsync(cancellationToken); + + var (latestMap, _) = await GetDeviceMetaAsync([id], cancellationToken); + + return new AdminDeviceDetailDto + { + Id = device.Id, + DeviceName = device.DeviceName, + Location = device.Location, + OwnerName = device.User.Name, + OwnerFamily = device.User.Family, + OwnerMobile = device.User.Mobile, + IsOnline = latestMap.TryGetValue(id, out var last) && last.ServerTimestampUtc >= DateTime.UtcNow.AddMinutes(-_onlineWindowMinutes), + MemberCount = memberCount, + ProductType = settings?.ProductType, + Province = settings?.Province, + City = settings?.City, + DevicePhoneNumber = settings?.DevicePhoneNumber, + UploadIntervalMin = settings?.UploadIntervalMin, + RelayCount = settings?.RelayCount, + LastTelemetry = _mapper.Map>(lastTelemetry) + }; + } + + // ==================== Usage ==================== + + public async Task> GetUsageAsync(DateTime? from, DateTime? to, int? userId, CancellationToken cancellationToken) + { + // default window: last 30 days + var fromDate = DateOnly.FromDateTime((from ?? DateTime.UtcNow.AddDays(-30)).Date); + var toDate = DateOnly.FromDateTime(to?.Date ?? DateTime.UtcNow.Date); + + IQueryable query = _dbContext.UsageCounters.AsNoTracking() + .Where(u => u.Date >= fromDate && u.Date <= toDate); + + if (userId.HasValue) + query = query.Where(u => u.UserId == userId.Value); + + var rows = await query + .GroupBy(u => u.Date) + .Select(g => new + { + Date = g.Key, + Sms = g.Sum(x => x.SmsCount), + Voice = g.Sum(x => x.VoiceCallCount), + AiQueries = g.Sum(x => x.AiQueryCount), + AiTokens = g.Sum(x => x.AiTokenCount) + }) + .OrderBy(r => r.Date) + .ToListAsync(cancellationToken); + + return rows.Select(r => new UsageDayDto + { + Date = r.Date.ToDateTime(new TimeOnly(0, 0)), + SmsCount = r.Sms, + VoiceCallCount = r.Voice, + AiQueryCount = r.AiQueries, + AiTokenCount = r.AiTokens + }).ToList(); + } + + // ==================== Helpers ==================== + + /// + /// آخرین تلیمتری هر دستگاه + نام مالک (برای لیست‌های admin). + /// + private async Task<(Dictionary Latest, Dictionary OwnerNames)> GetDeviceMetaAsync( + IReadOnlyList deviceIds, CancellationToken cancellationToken) + { + if (deviceIds.Count == 0) + return (new Dictionary(), new Dictionary()); + + var latestRows = await _dbContext.TelemetryRecords + .AsNoTracking() + .Where(t => deviceIds.Contains(t.DeviceId)) + .GroupBy(t => t.DeviceId) + .Select(g => g.OrderByDescending(t => t.ServerTimestampUtc).First()) + .ToListAsync(cancellationToken); + + var latest = latestRows.ToDictionary(t => t.DeviceId); + + var owners = await _dbContext.Devices + .Where(d => deviceIds.Contains(d.Id)) + .Select(d => new { d.Id, Owner = d.User.Name + " " + d.User.Family }) + .ToDictionaryAsync(x => x.Id, x => x.Owner, cancellationToken); + + return (latest, owners); + } + + private async Task> BuildDeviceListAsync( + IReadOnlyList deviceIds, + Dictionary latestMap, + CancellationToken cancellationToken) + { + if (deviceIds.Count == 0) return []; + + var devices = await _dbContext.Devices.AsNoTracking() + .Include(d => d.User) + .Where(d => deviceIds.Contains(d.Id)) + .ToListAsync(cancellationToken); + + return devices.Select(d => ToDeviceListItem(d, latestMap)).ToList(); + } + + /// + /// Persian (۰-۹) و Arabic (٠-٩) digits را به آستانه‌ای انگلیسی تبدیل می‌کند تا search با ارقام فارسی هم کار کند. + /// (MobileNormalizer برای فرمت یکپارچه موبایل است و برای رشته‌ی general search قابل استفاده نیست.) + /// + private static string NormalizeDigits(string input) + { + var chars = input.ToCharArray(); + for (var i = 0; i < chars.Length; i++) + { + var c = chars[i]; + if (c is >= '۰' and <= '۹') chars[i] = (char)('0' + c - '۰'); + else if (c is >= '٠' and <= '٩') chars[i] = (char)('0' + c - '٠'); + } + return new string(chars); + } + + private AdminDeviceListItemDto ToDeviceListItem(Domain.Device device, Dictionary latestMap) + { + latestMap.TryGetValue(device.Id, out var last); + var online = last != null && last.ServerTimestampUtc >= DateTime.UtcNow.AddMinutes(-_onlineWindowMinutes); + + return new AdminDeviceListItemDto + { + Id = device.Id, + DeviceName = device.DeviceName, + Location = device.Location, + OwnerName = device.User.Name + " " + device.User.Family, + OwnerMobile = device.User.Mobile, + IsOnline = online, + LastTelemetryAt = last?.ServerTimestampUtc, + TemperatureC = last?.TemperatureC, + HumidityPercent = last?.HumidityPercent + }; + } +} diff --git a/src/GreenHome.Infrastructure/Services/Alert/AlertNotificationSender.cs b/src/GreenHome.Infrastructure/Services/Alert/AlertNotificationSender.cs index 56a8bcd..7b8c20e 100644 --- a/src/GreenHome.Infrastructure/Services/Alert/AlertNotificationSender.cs +++ b/src/GreenHome.Infrastructure/Services/Alert/AlertNotificationSender.cs @@ -1,6 +1,7 @@ using GreenHome.Application; using GreenHome.Domain; using GreenHome.Infrastructure.Data; +using GreenHome.Infrastructure.Services.Usage; using GreenHome.Sms.Ippanel; using GreenHome.VoiceCall.Avanak; using Microsoft.EntityFrameworkCore; @@ -21,17 +22,20 @@ public sealed class AlertNotificationSender private readonly GreenHomeDbContext _dbContext; private readonly ISmsService _smsService; private readonly IVoiceCallService _voiceCallService; + private readonly UsageTracker _usageTracker; private readonly ILogger _logger; public AlertNotificationSender( GreenHomeDbContext dbContext, ISmsService smsService, IVoiceCallService voiceCallService, + UsageTracker usageTracker, ILogger logger) { _dbContext = dbContext; _smsService = smsService; _voiceCallService = voiceCallService; + _usageTracker = usageTracker; _logger = logger; } @@ -80,7 +84,7 @@ public sealed class AlertNotificationSender // Send return await SendAsync( - user.Mobile, device.DeviceName, message, + user.Mobile, user.Id, device.DeviceName, message, condition.NotificationType, cancellationToken); } @@ -94,11 +98,16 @@ public sealed class AlertNotificationSender AlertNotificationType notificationType, CancellationToken cancellationToken) { - return await SendAsync(mobile, deviceName, message, notificationType, cancellationToken); + var user = await _dbContext.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.Mobile == mobile, cancellationToken); + + return await SendAsync(mobile, user?.Id ?? 0, deviceName, message, notificationType, cancellationToken); } private async Task SendAsync( string mobile, + int userId, string deviceName, string message, AlertNotificationType notificationType, @@ -110,11 +119,11 @@ public sealed class AlertNotificationSender { if (notificationType == AlertNotificationType.SMS) { - return await SendSmsAsync(mobile, deviceName, message, startTime, cancellationToken); + return await SendSmsAsync(mobile, userId, deviceName, message, startTime, cancellationToken); } else { - return await SendCallAsync(mobile, deviceName, message, startTime, cancellationToken); + return await SendCallAsync(mobile, userId, deviceName, message, startTime, cancellationToken); } } catch (Exception ex) @@ -133,6 +142,7 @@ public sealed class AlertNotificationSender private async Task SendSmsAsync( string mobile, + int userId, string deviceName, string message, DateTime startTime, @@ -159,6 +169,7 @@ public sealed class AlertNotificationSender { var outboxIds = JsonSerializer.Serialize(smsResponse.Data.MessageOutboxIds); _logger.LogInformation("Alert SMS sent: Mobile={Mobile}, OutboxIds={OutboxIds}", mobile, outboxIds); + await _usageTracker.RecordAsync(userId, UsageKind.Sms, cancellationToken: cancellationToken); return new SendResult(true, outboxIds, null, processingTime); } @@ -178,6 +189,7 @@ public sealed class AlertNotificationSender private async Task SendCallAsync( string mobile, + int userId, string deviceName, string message, DateTime startTime, @@ -186,6 +198,7 @@ public sealed class AlertNotificationSender try { _logger.LogInformation("Voice call alert requested: Mobile={Mobile}, Message={Message}", mobile, message); + // ponytail: صوتی فعلاً stub است؛ ثبت مصرف را وقتی call واقعی پیاده شد همان‌جا (بعد از success) اضافه کن var processingTime = (long)(DateTime.UtcNow - startTime).TotalMilliseconds; return new SendResult(true, null, "Voice call not yet implemented", processingTime); } diff --git a/src/GreenHome.Infrastructure/Services/Auth/AuthService.cs b/src/GreenHome.Infrastructure/Services/Auth/AuthService.cs index 59cc98e..4ab836c 100644 --- a/src/GreenHome.Infrastructure/Services/Auth/AuthService.cs +++ b/src/GreenHome.Infrastructure/Services/Auth/AuthService.cs @@ -1,5 +1,6 @@ using GreenHome.Application; using GreenHome.Infrastructure.Data; +using GreenHome.Infrastructure.Services.Usage; using GreenHome.Sms.Ippanel; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -13,6 +14,7 @@ public sealed class AuthService : IAuthService { private readonly GreenHomeDbContext _dbContext; private readonly ISmsService _smsService; + private readonly UsageTracker _usageTracker; private readonly ILogger _logger; private readonly string _tokenSecret; private const int CodeExpirationMinutes = 5; @@ -21,11 +23,13 @@ public sealed class AuthService : IAuthService public AuthService( GreenHomeDbContext dbContext, ISmsService smsService, + UsageTracker usageTracker, IConfiguration configuration, ILogger logger) { _dbContext = dbContext; _smsService = smsService; + _usageTracker = usageTracker; _logger = logger; _tokenSecret = configuration["Auth:TokenSecret"] ?? "greenhome-dev-secret-change-me"; } @@ -45,6 +49,19 @@ public sealed class AuthService : IAuthService }; } + var existingUser = await _dbContext.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.Mobile == mobile, cancellationToken); + + if (existingUser != null && existingUser.IsDisabled) + { + return new SendCodeResponse + { + Success = false, + Message = "حساب شما غیرفعال شده است. لطفاً با پشتیبانی تماس بگیرید." + }; + } + var canResend = await CanResendCodeAsync(mobile, cancellationToken); if (!canResend) { @@ -98,6 +115,9 @@ public sealed class AuthService : IAuthService Variables = new Dictionary { { "code", code } } }, cancellationToken); + if (existingUser != null) + await _usageTracker.RecordAsync(existingUser.Id, UsageKind.Sms, cancellationToken: cancellationToken); + _logger.LogInformation("Verification code sent to {Mobile}", mobile); } catch (Exception ex) @@ -149,6 +169,15 @@ public sealed class AuthService : IAuthService var user = await _dbContext.Users .FirstOrDefaultAsync(u => u.Mobile == mobile, cancellationToken); + if (user != null && user.IsDisabled) + { + return new VerifyCodeResponse + { + Success = false, + Message = "حساب شما غیرفعال شده است. لطفاً با پشتیبانی تماس بگیرید." + }; + } + if (user == null) { user = new Domain.User @@ -179,7 +208,8 @@ public sealed class AuthService : IAuthService Mobile = user.Mobile, Name = user.Name, Family = user.Family, - Role = user.Role + Role = user.Role, + IsDisabled = user.IsDisabled } }; } @@ -233,13 +263,17 @@ public sealed class AuthService : IAuthService if (user == null) return null; + if (user.IsDisabled) + return null; + return new UserDto { Id = user.Id, Mobile = user.Mobile, Name = user.Name, Family = user.Family, - Role = user.Role + Role = user.Role, + IsDisabled = user.IsDisabled }; } diff --git a/src/GreenHome.Infrastructure/Services/Device/DeviceTokenService.cs b/src/GreenHome.Infrastructure/Services/Device/DeviceTokenService.cs index 20967e4..cfc3249 100644 --- a/src/GreenHome.Infrastructure/Services/Device/DeviceTokenService.cs +++ b/src/GreenHome.Infrastructure/Services/Device/DeviceTokenService.cs @@ -1,5 +1,6 @@ using GreenHome.Application; using GreenHome.Infrastructure.Data; +using GreenHome.Infrastructure.Services.Usage; using GreenHome.Sms.Ippanel; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -10,15 +11,18 @@ public sealed class DeviceTokenService : IDeviceTokenService { private readonly GreenHomeDbContext _dbContext; private readonly ISmsService _smsService; + private readonly UsageTracker _usageTracker; private readonly ILogger _logger; public DeviceTokenService( GreenHomeDbContext dbContext, ISmsService smsService, + UsageTracker usageTracker, ILogger logger) { _dbContext = dbContext; _smsService = smsService; + _usageTracker = usageTracker; _logger = logger; } @@ -76,6 +80,7 @@ public sealed class DeviceTokenService : IDeviceTokenService try { var settings = await _dbContext.DeviceSettings + .Include(ds => ds.Device) .FirstOrDefaultAsync(ds => ds.DevicePhoneNumber == request.DevicePhoneNumber, cancellationToken); if (settings == null) @@ -121,6 +126,7 @@ public sealed class DeviceTokenService : IDeviceTokenService }; } + await _usageTracker.RecordAsync(settings.Device.UserId, UsageKind.Sms, cancellationToken: cancellationToken); _logger.LogInformation("Token requested for device phone {PhoneNumber}", request.DevicePhoneNumber); return new RequestDeviceTokenResponse @@ -211,6 +217,7 @@ public sealed class DeviceTokenService : IDeviceTokenService }; } + await _usageTracker.RecordAsync(settings.Device.UserId, UsageKind.Sms, cancellationToken: cancellationToken); settings.TokenCode = null; settings.VerificationCode = null; settings.TokenExpiresAt = null; diff --git a/src/GreenHome.Infrastructure/Services/Usage/UsageTracker.cs b/src/GreenHome.Infrastructure/Services/Usage/UsageTracker.cs new file mode 100644 index 0000000..0bff603 --- /dev/null +++ b/src/GreenHome.Infrastructure/Services/Usage/UsageTracker.cs @@ -0,0 +1,69 @@ +using GreenHome.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace GreenHome.Infrastructure.Services.Usage; + +public enum UsageKind +{ + Sms, + VoiceCall, + AiQuery +} + +/// +/// افزایش‌دهنده شمارنده مصرف روزانه کاربران. +/// تک نقطه‌ای است که همه مسیرهای ارسال SMS / تماس صوتی / query AI از آن عبور می‌کنند. +/// خطا در شمارش مصرف نباید ارسال اعلان را خراب کند، بنابراین خطا فقط لاگ می‌شود. +/// +public sealed class UsageTracker +{ + private readonly GreenHomeDbContext _dbContext; + private readonly ILogger _logger; + + public UsageTracker(GreenHomeDbContext dbContext, ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + public async Task RecordAsync(int userId, UsageKind kind, int tokens = 0, CancellationToken cancellationToken = default) + { + if (userId <= 0) return; + + try + { + // ponytail: روز بر اساس UTC (DateOnly) — اگر در آینده نیاز به «روز شمسی» بود اینجا جایگزین می‌شود. + var today = DateOnly.FromDateTime(DateTime.UtcNow); + + var counter = await _dbContext.UsageCounters + .FirstOrDefaultAsync(u => u.UserId == userId && u.Date == today, cancellationToken); + + if (counter == null) + { + counter = new Domain.UsageCounter { UserId = userId, Date = today }; + _dbContext.UsageCounters.Add(counter); + } + + switch (kind) + { + case UsageKind.Sms: + counter.SmsCount++; + break; + case UsageKind.VoiceCall: + counter.VoiceCallCount++; + break; + case UsageKind.AiQuery: + counter.AiQueryCount++; + counter.AiTokenCount += tokens; + break; + } + + await _dbContext.SaveChangesAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to record usage: UserId={UserId}, Kind={Kind}", userId, kind); + } + } +} diff --git a/src/GreenHome.Web/src/App.tsx b/src/GreenHome.Web/src/App.tsx index 0403f88..7d752e0 100644 --- a/src/GreenHome.Web/src/App.tsx +++ b/src/GreenHome.Web/src/App.tsx @@ -1,6 +1,6 @@ import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import ServiceWorkerManager from '@/components/ServiceWorkerManager' -import { DeviceProvider, AuthGuard } from '@/components/layout' +import { DeviceProvider, AuthGuard, AdminGuard } from '@/components/layout' import Home from '@/app/page' import LoginPage from '@/app/login/page' import VerifyCodePage from '@/app/verify-code/page' @@ -12,6 +12,11 @@ import WeatherPage from '@/app/weather/page' import ProfilePage from '@/app/profile/page' import DevicePage from '@/app/device/page' import GroupPage from '@/app/group/page' +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 AdminUsagePage from '@/app/admin/usage/page' function DeviceRedirect() { const location = useLocation() @@ -37,6 +42,12 @@ export default function App() { } /> } /> } /> + }> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/src/GreenHome.Web/src/app/admin/AdminLayout.tsx b/src/GreenHome.Web/src/app/admin/AdminLayout.tsx new file mode 100644 index 0000000..4437b61 --- /dev/null +++ b/src/GreenHome.Web/src/app/admin/AdminLayout.tsx @@ -0,0 +1,83 @@ +'use client' + +import { Outlet, useLocation } from 'react-router-dom' +import { Link } from '@/lib/router' +import { LayoutDashboard, Users, Cpu, BarChart3, ArrowRight } 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/usage', label: 'مصرف', icon: BarChart3, exact: false }, +] + +export default function AdminLayout() { + const pathname = useLocation().pathname + + const isActive = (item: (typeof NAV_ITEMS)[number]) => + item.exact ? pathname === item.href : pathname.startsWith(item.href) + + const navLinks = (onClick?: () => void) => + NAV_ITEMS.map((item) => { + const Icon = item.icon + const active = isActive(item) + return ( + + + {item.label} + + ) + }) + + return ( +
+ {/* Desktop sidebar (right side in RTL) */} + + + {/* Mobile top bar */} +
+
+

پنل مدیریت

+ + + خروج + +
+ +
+ + {/* Page content */} +
+
+ +
+
+
+ ) +} diff --git a/src/GreenHome.Web/src/app/admin/devices/page.tsx b/src/GreenHome.Web/src/app/admin/devices/page.tsx new file mode 100644 index 0000000..c26add3 --- /dev/null +++ b/src/GreenHome.Web/src/app/admin/devices/page.tsx @@ -0,0 +1,231 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { Cpu, Thermometer, Eye } from 'lucide-react' +import { api, PagedResult, AdminDeviceListItem, AdminDeviceDetail } from '@/lib/api' +import { toPersianDigits } from '@/lib/format/persian-digits' +import { formatPersianDateFromIso } from '@/lib/date/persian-date' +import { SearchInput } from '@/components/forms/SearchInput' +import { Pagination } from '@/components/navigation/Pagination' +import { Badge } from '@/components/common/Badge' +import { Button } from '@/components/common/Button' +import { Modal } from '@/components/common/Modal' +import { EmptyState } from '@/components/common/EmptyState' + +const PAGE_SIZE = 20 + +export default function AdminDevicesPage() { + const [result, setResult] = useState | null>(null) + const [search, setSearch] = useState('') + const [appliedSearch, setAppliedSearch] = useState('') + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [detail, setDetail] = useState(null) + + const fetchDevices = useCallback(async (p: number, s: string) => { + setLoading(true) + setError('') + try { + const data = await api.adminDevices({ search: s || undefined, page: p, pageSize: PAGE_SIZE }) + setResult(data) + } catch (e) { + setError((e as Error).message || 'خطا در دریافت دستگاه‌ها') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchDevices(page, appliedSearch) + }, [page, appliedSearch, fetchDevices]) + + const handleSearch = (value?: string) => { + const v = value ?? search + setSearch(v) + setAppliedSearch(v) + setPage(1) + } + + const openDetail = async (id: number) => { + try { + setDetail(await api.adminDevice(id)) + } catch (e) { + setError((e as Error).message || 'خطا در دریافت جزئیات دستگاه') + } + } + + const totalPages = result ? Math.max(1, Math.ceil(result.totalCount / PAGE_SIZE)) : 1 + const showPagination = totalPages > 1 + + return ( +
+
+

دستگاه‌ها

+ {result && ( + + {toPersianDigits(result.totalCount)} دستگاه + + )} +
+ + handleSearch('')} + placeholder="جستجو بر اساس نام دستگاه یا مالک..." + /> + + {error &&

{error}

} + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+ ))} +
+ ) : result && result.items.length > 0 ? ( + <> +
+ + + + + + + + + + + + + + + {result.items.map((device) => ( + + + + + + + + + + + ))} + +
دستگاهموقعیتمالکدمارطوبتآخرین تله‌متریوضعیت
{device.deviceName}{device.location || '—'} +
{device.ownerName}
+
{toPersianDigits(device.ownerMobile)}
+
+ {device.temperatureC != null ? `${toPersianDigits(device.temperatureC)}°` : '—'} + + {device.humidityPercent != null ? `${toPersianDigits(device.humidityPercent)}٪` : '—'} + + {device.lastTelemetryAt ? formatPersianDateFromIso(device.lastTelemetryAt) : '—'} + + {device.isOnline + ? آنلاین + : آفلاین} + + +
+
+ {showPagination && ( + + )} + + ) : ( + + )} + + setDetail(null)} title={detail ? `دستگاه: ${detail.deviceName}` : ''} size="lg"> + {detail && ( +
+
+
+

مالک

+

{detail.ownerName} {detail.ownerFamily}

+

{toPersianDigits(detail.ownerMobile)}

+
+
+

وضعیت

+ {detail.isOnline + ? آنلاین + : آفلاین} +
+
+

شماره دستگاه

+

{detail.devicePhoneNumber ? toPersianDigits(detail.devicePhoneNumber) : '—'}

+
+
+

اعضا

+

{toPersianDigits(detail.memberCount)} نفر

+
+ {detail.productType && ( +
+

نوع محصول

+

{detail.productType}

+
+ )} + {detail.uploadIntervalMin != null && ( +
+

بازه بارگذاری

+

{toPersianDigits(detail.uploadIntervalMin)} دقیقه

+
+ )} + {detail.relayCount != null && ( +
+

تعداد رله

+

{toPersianDigits(detail.relayCount)}

+
+ )} +
+ +
+

+ + آخرین تله‌متری‌ها ({toPersianDigits(detail.lastTelemetry.length)}) +

+ {detail.lastTelemetry.length === 0 ? ( +

داده‌ای ثبت نشده است.

+ ) : ( +
+ + + + + + + + + + + {detail.lastTelemetry.map((t) => ( + + + + + + + ))} + +
تاریخدما (°)رطوبت (٪)خاک (٪)
{formatPersianDateFromIso(t.timestampUtc)}{toPersianDigits(t.temperatureC)}{toPersianDigits(t.humidityPercent)}{toPersianDigits(t.soilPercent)}
+
+ )} +
+
+ )} +
+
+ ) +} diff --git a/src/GreenHome.Web/src/app/admin/page.tsx b/src/GreenHome.Web/src/app/admin/page.tsx new file mode 100644 index 0000000..ac7b902 --- /dev/null +++ b/src/GreenHome.Web/src/app/admin/page.tsx @@ -0,0 +1,114 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Users, Cpu, Wifi, WifiOff, Bell, MessageSquare, Bot } from 'lucide-react' +import { api, AdminDashboard, AdminUsageDay } from '@/lib/api' +import { toPersianDigits } from '@/lib/format/persian-digits' +import { gregorianToPersian } from '@/lib/date/persian-date' +import { formatPersianDate } from '@/lib/format/persian-date' + +const MONTH_NAMES = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'] + +function KpiCard({ icon: Icon, label, value, accent = false }: { + icon: typeof Users + label: string + value: number + accent?: boolean +}) { + return ( +
+
+
+ +
+
+

{toPersianDigits(value)}

+

{label}

+
+
+
+ ) +} + +function SmsSparkline({ data }: { data: AdminUsageDay[] }) { + const values = data.map((d) => d.smsCount) + const max = Math.max(...values, 1) + const W = 280 + const H = 56 + const step = W / Math.max(values.length - 1, 1) + const points = values.map((v, i) => { + const x = i * step + const y = H - (v / max) * (H - 8) - 4 + return `${x.toFixed(1)},${y.toFixed(1)}` + }).join(' ') + + const labels = data.map((d) => { + const p = gregorianToPersian(new Date(d.date)) + return `${MONTH_NAMES[p.month - 1]} ${toPersianDigits(p.day)}` + }) + + return ( +
+

پیامک‌های ۷ روز اخیر

+
+ + + +
+ {labels.map((l, i) => ( + {i % 2 === 0 ? l : ''} + ))} +
+
+
+ ) +} + +export default function AdminDashboardPage() { + const [data, setData] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + api.adminDashboard() + .then(setData) + .catch((e) => setError(e.message || 'خطا در دریافت اطلاعات')) + }, []) + + if (error) { + return

{error}

+ } + + const today = gregorianToPersian(new Date()) + const todayStr = formatPersianDate(today.year, today.month, today.day) + + if (!data) { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+ ))} +
+ ) + } + + return ( +
+
+ + + + + + + +
+

امروز

+

{todayStr}

+
+
+ +
+ ) +} diff --git a/src/GreenHome.Web/src/app/admin/usage/page.tsx b/src/GreenHome.Web/src/app/admin/usage/page.tsx new file mode 100644 index 0000000..71aa289 --- /dev/null +++ b/src/GreenHome.Web/src/app/admin/usage/page.tsx @@ -0,0 +1,170 @@ +'use client' + +import { useCallback, useEffect, useState, type ChangeEvent } from 'react' +import { BarChart3, MessageSquare, Phone, Bot, Hash } from 'lucide-react' +import { api, AdminUsageDay } from '@/lib/api' +import { toPersianDigits } from '@/lib/format/persian-digits' +import { formatPersianDateFromIso } from '@/lib/date/persian-date' +import { Button } from '@/components/common/Button' +import { EmptyState } from '@/components/common/EmptyState' + +type RangePreset = 7 | 30 | 90 + +function toIsoDate(d: Date): string { + return d.toISOString().slice(0, 10) +} + +function daysAgo(n: number): string { + const d = new Date() + d.setDate(d.getDate() - n) + return toIsoDate(d) +} + +export default function AdminUsagePage() { + const [days, setDays] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [from, setFrom] = useState(daysAgo(29)) + const [to, setTo] = useState(toIsoDate(new Date())) + const [preset, setPreset] = useState(30) + + const fetchUsage = useCallback(async (f: string, t: string) => { + setLoading(true) + setError('') + try { + const data = await api.adminUsage({ from: f, to: t }) + setDays(data) + } catch (e) { + setError((e as Error).message || 'خطا در دریافت مصرف') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchUsage(from, to) + }, [from, to, fetchUsage]) + + const applyPreset = (n: RangePreset) => { + setPreset(n) + setFrom(daysAgo(n - 1)) + setTo(toIsoDate(new Date())) + } + + const onDateChange = (setter: (v: string) => void) => (e: ChangeEvent) => { + setter(e.target.value) + setPreset(null) + } + + const totals = days.reduce( + (acc, d) => ({ + sms: acc.sms + d.smsCount, + calls: acc.calls + d.voiceCallCount, + queries: acc.queries + d.aiQueryCount, + tokens: acc.tokens + d.aiTokenCount, + }), + { sms: 0, calls: 0, queries: 0, tokens: 0 } + ) + + return ( +
+
+

مصرف

+
+ + تا + +
+
+ +
+ {([7, 30, 90] as RangePreset[]).map((n) => ( + + ))} +
+ +
+ + + + +
+ + {error &&

{error}

} + + {loading ? ( +
+ {Array.from({ length: 7 }).map((_, i) => ( +
+
+
+ ))} +
+ ) : days.length > 0 ? ( +
+ + + + + + + + + + + + {[...days].reverse().map((d) => ( + + + + + + + + ))} + +
تاریخپیامکتماس صوتیدرخواست AIتوکن AI
{formatPersianDateFromIso(d.date)}{toPersianDigits(d.smsCount)}{toPersianDigits(d.voiceCallCount)}{toPersianDigits(d.aiQueryCount)}{toPersianDigits(d.aiTokenCount)}
+
+ ) : ( + + )} +
+ ) +} + +function SummaryCard({ icon: Icon, label, value }: { icon: typeof BarChart3; label: string; value: number }) { + return ( +
+
+
+ +
+
+

{toPersianDigits(value)}

+

{label}

+
+
+
+ ) +} diff --git a/src/GreenHome.Web/src/app/admin/users/page.tsx b/src/GreenHome.Web/src/app/admin/users/page.tsx new file mode 100644 index 0000000..600cfc4 --- /dev/null +++ b/src/GreenHome.Web/src/app/admin/users/page.tsx @@ -0,0 +1,229 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { Users, ShieldCheck, ShieldOff, Cpu, UserX } from 'lucide-react' +import { api, PagedResult, AdminUserListItem, AdminUserDetail } from '@/lib/api' +import { toPersianDigits } from '@/lib/format/persian-digits' +import { formatPersianDateFromIso } from '@/lib/date/persian-date' +import { SearchInput } from '@/components/forms/SearchInput' +import { Pagination } from '@/components/navigation/Pagination' +import { Badge } from '@/components/common/Badge' +import { Button } from '@/components/common/Button' +import { Modal } from '@/components/common/Modal' +import { EmptyState } from '@/components/common/EmptyState' + +const PAGE_SIZE = 20 + +const ROLE_NAMES: Record = { 0: 'کاربر', 1: 'مدیر', 2: 'ناظر' } +const ROLE_VARIANTS: Record = { 0: 'default' as never, 1: 'success', 2: 'warning' } + +export default function AdminUsersPage() { + const [result, setResult] = useState | null>(null) + const [search, setSearch] = useState('') + const [appliedSearch, setAppliedSearch] = useState('') + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [detail, setDetail] = useState(null) + const [busyId, setBusyId] = useState(null) + + const fetchUsers = useCallback(async (p: number, s: string) => { + setLoading(true) + setError('') + try { + const data = await api.adminUsers({ search: s || undefined, page: p, pageSize: PAGE_SIZE }) + setResult(data) + } catch (e) { + setError((e as Error).message || 'خطا در دریافت کاربران') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchUsers(page, appliedSearch) + }, [page, appliedSearch, fetchUsers]) + + const handleSearch = (value?: string) => { + const v = value ?? search + setSearch(v) + setAppliedSearch(v) + setPage(1) + } + + const openDetail = async (id: number) => { + try { + setDetail(await api.adminUser(id)) + } catch (e) { + setError((e as Error).message || 'خطا در دریافت جزئیات کاربر') + } + } + + const runAction = async (id: number, action: () => Promise) => { + setBusyId(id) + setError('') + try { + await action() + await fetchUsers(page, appliedSearch) + } catch (e) { + setError((e as Error).message || 'خطا در انجام عملیات') + } finally { + setBusyId(null) + } + } + + const setRole = (id: number, role: number) => + runAction(id, () => api.adminSetUserRole(id, role)) + const toggleStatus = (user: AdminUserListItem) => + runAction(user.id, () => api.adminSetUserStatus(user.id, user.isDisabled)) + + const totalPages = result ? Math.max(1, Math.ceil(result.totalCount / PAGE_SIZE)) : 1 + const showPagination = totalPages > 1 + + return ( +
+
+

کاربران

+ {result && ( + + {toPersianDigits(result.totalCount)} کاربر + + )} +
+ + handleSearch('')} + placeholder="جستجو بر اساس نام یا شماره موبایل..." + /> + + {error &&

{error}

} + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+ ))} +
+ ) : result && result.items.length > 0 ? ( + <> +
+ + + + + + + + + + + + + {result.items.map((user) => ( + + + + + + + + + ))} + +
کاربرنقشدستگاهآخرین ورودوضعیتعملیات
+
{user.name} {user.family}
+
{toPersianDigits(user.mobile)}
+
+ + {ROLE_NAMES[user.role] ?? 'کاربر'} + + {toPersianDigits(user.deviceCount)} + {user.lastLoginAt ? formatPersianDateFromIso(user.lastLoginAt) : '—'} + + {user.isDisabled + ? غیرفعال + : فعال} + +
+ + + +
+
+
+ {showPagination && ( + + )} + + ) : ( + + )} + + setDetail(null)} title={detail ? `کاربر: ${detail.name} ${detail.family}` : ''} size="lg"> + {detail && ( +
+
+
+

موبایل

+

{toPersianDigits(detail.mobile)}

+
+
+

تاریخ عضویت

+

{formatPersianDateFromIso(detail.createdAt)}

+
+
+

نقش

+

{ROLE_NAMES[detail.role] ?? 'کاربر'}

+
+
+

وضعیت

+

{detail.isDisabled ? 'غیرفعال' : 'فعال'}

+
+
+ +
+

+ دستگاه‌ها ({toPersianDigits(detail.devices.length)}) +

+ {detail.devices.length === 0 ? ( +

دستگاهی ندارد.

+ ) : ( +
+ {detail.devices.map((d) => ( +
+
+

{d.deviceName}

+

{d.location}

+
+ {d.isOnline + ? آنلاین + : آفلاین} +
+ ))} +
+ )} +
+
+ )} +
+
+ ) +} diff --git a/src/GreenHome.Web/src/components/layout/AdminGuard.tsx b/src/GreenHome.Web/src/components/layout/AdminGuard.tsx new file mode 100644 index 0000000..c0e2112 --- /dev/null +++ b/src/GreenHome.Web/src/components/layout/AdminGuard.tsx @@ -0,0 +1,25 @@ +'use client' + +import { ReactNode } from 'react' +import { Navigate } from 'react-router-dom' + +const ADMIN_ROLE = 1 + +function isAdmin(): boolean { + if (typeof window === 'undefined') return false + try { + const raw = localStorage.getItem('user') + if (!raw) return false + const user = JSON.parse(raw) as { role?: number } + return user.role === ADMIN_ROLE + } catch { + return false + } +} + +export function AdminGuard({ children }: { children: ReactNode }) { + if (!isAdmin()) { + return + } + return <>{children} +} diff --git a/src/GreenHome.Web/src/components/layout/index.ts b/src/GreenHome.Web/src/components/layout/index.ts index 4061357..0861b1a 100644 --- a/src/GreenHome.Web/src/components/layout/index.ts +++ b/src/GreenHome.Web/src/components/layout/index.ts @@ -3,3 +3,4 @@ export { BottomNav } from './BottomNav' export { TopHeader } from './TopHeader' export { DeviceProvider, useDevice } from './DeviceProvider' export { AuthGuard } from './AuthGuard' +export { AdminGuard } from './AdminGuard' diff --git a/src/GreenHome.Web/src/lib/api/client.ts b/src/GreenHome.Web/src/lib/api/client.ts index 138c0fa..b4fdd80 100644 --- a/src/GreenHome.Web/src/lib/api/client.ts +++ b/src/GreenHome.Web/src/lib/api/client.ts @@ -18,6 +18,12 @@ import type { UpsertDeviceMemberRequest, DevicePostDto, DevicePostImageDto, + AdminDashboard, + AdminUserListItem, + AdminUserDetail, + AdminDeviceListItem, + AdminDeviceDetail, + AdminUsageDay, } from './types' const API_BASE = import.meta.env.VITE_API_URL ?? '' @@ -203,4 +209,34 @@ export const api = { method: 'POST', body: JSON.stringify({ deviceId, authorUserId }), }), + + // Admin + adminDashboard: () => http(`${API_BASE}/api/admin/dashboard`), + adminUsers: (q: { search?: string; page?: number; pageSize?: number }) => { + const params = new URLSearchParams() + if (q.search) params.set('search', q.search) + if (q.page) params.set('page', String(q.page)) + if (q.pageSize) params.set('pageSize', String(q.pageSize)) + return http>(`${API_BASE}/api/admin/users?${params.toString()}`) + }, + adminUser: (id: number) => http(`${API_BASE}/api/admin/users/${id}`), + adminSetUserRole: (id: number, role: number) => + http(`${API_BASE}/api/admin/users/${id}/role`, { method: 'PUT', body: JSON.stringify({ role }) }), + adminSetUserStatus: (id: number, isEnabled: boolean) => + http(`${API_BASE}/api/admin/users/${id}/status`, { method: 'PUT', body: JSON.stringify({ isEnabled }) }), + adminDevices: (q: { search?: string; page?: number; pageSize?: number }) => { + const params = new URLSearchParams() + if (q.search) params.set('search', q.search) + if (q.page) params.set('page', String(q.page)) + if (q.pageSize) params.set('pageSize', String(q.pageSize)) + return http>(`${API_BASE}/api/admin/devices?${params.toString()}`) + }, + adminDevice: (id: number) => http(`${API_BASE}/api/admin/devices/${id}`), + adminUsage: (q: { from?: string; to?: string; userId?: number }) => { + const params = new URLSearchParams() + if (q.from) params.set('from', q.from) + if (q.to) params.set('to', q.to) + if (q.userId) params.set('userId', String(q.userId)) + return http(`${API_BASE}/api/admin/usage?${params.toString()}`) + }, } diff --git a/src/GreenHome.Web/src/lib/api/types.ts b/src/GreenHome.Web/src/lib/api/types.ts index e9b56b9..5b8c9cf 100644 --- a/src/GreenHome.Web/src/lib/api/types.ts +++ b/src/GreenHome.Web/src/lib/api/types.ts @@ -275,3 +275,70 @@ export type DevicePostDto = { updatedAt?: string } +// ==================== Admin ==================== + +export type AdminDashboard = { + totalUsers: number + totalDevices: number + onlineDevices: number + offlineDevices: number + alertsLast24h: number + smsLast7Days: number + aiQueriesLast7Days: number + smsDailyLast7Days: AdminUsageDay[] +} + +export type AdminUserListItem = { + id: number + name: string + family: string + mobile: string + role: number + isDisabled: boolean + deviceCount: number + lastLoginAt?: string + createdAt: string +} + +export type AdminUserDetail = AdminUserListItem & { + devices: AdminDeviceListItem[] +} + +export type AdminDeviceListItem = { + id: number + deviceName: string + location: string + ownerName: string + ownerMobile: string + isOnline: boolean + lastTelemetryAt?: string + temperatureC?: number + humidityPercent?: number +} + +export type AdminDeviceDetail = { + id: number + deviceName: string + location: string + ownerName: string + ownerFamily: string + ownerMobile: string + isOnline: boolean + memberCount: number + productType?: string + province?: string + city?: string + devicePhoneNumber?: string + uploadIntervalMin?: number + relayCount?: number + lastTelemetry: TelemetryDto[] +} + +export type AdminUsageDay = { + date: string + smsCount: number + voiceCallCount: number + aiQueryCount: number + aiTokenCount: number +} + diff --git a/src/GreenHome.Web/src/lib/date/persian-date.ts b/src/GreenHome.Web/src/lib/date/persian-date.ts index a366f3b..020bb3d 100644 --- a/src/GreenHome.Web/src/lib/date/persian-date.ts +++ b/src/GreenHome.Web/src/lib/date/persian-date.ts @@ -7,6 +7,7 @@ import { isLeapJalaaliYear, jalaaliMonthLength, } from 'jalaali-js' +import { formatPersianDate } from '@/lib/format/persian-date' export interface PersianDate { year: number @@ -24,6 +25,18 @@ export function persianToGregorian(year: number, month: number, day: number): Da return new Date(gy, gm - 1, gd) } +/** + * Format an ISO timestamp (or date string) to local Persian "yyyy/MM/dd". + * Returns an empty string for invalid dates. + */ +export function formatPersianDateFromIso(isoString: string): string { + if (!isoString) return '' + const date = new Date(isoString) + if (isNaN(date.getTime())) return '' + const { year, month, day } = gregorianToPersian(date) + return formatPersianDate(year, month, day) +} + export function getCurrentPersianDate(): PersianDate { return gregorianToPersian(new Date()) }