using System.Text.Json; using AutoMapper; using GreenHome.Application; using GreenHome.Application.Dtos.Admin; using GreenHome.Domain; using GreenHome.Infrastructure.Data; using GreenHome.Infrastructure.Services.Usage; using GreenHome.Sms.Ippanel; using GreenHome.VoiceCall.Avanak; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; namespace GreenHome.Infrastructure.Services.Admin; public sealed class AdminService : IAdminService { private readonly GreenHomeDbContext _dbContext; private readonly IMapper _mapper; private readonly ISmsService _smsService; private readonly IVoiceCallService _voiceCallService; private readonly UsageTracker _usageTracker; private readonly IWebHostEnvironment _environment; private readonly IDeviceService _deviceService; private readonly IDeviceSettingsService _deviceSettingsService; private readonly int _onlineWindowMinutes; public AdminService( GreenHomeDbContext dbContext, IMapper mapper, IConfiguration configuration, ISmsService smsService, IVoiceCallService voiceCallService, UsageTracker usageTracker, IWebHostEnvironment environment, IDeviceService deviceService, IDeviceSettingsService deviceSettingsService) { _dbContext = dbContext; _mapper = mapper; _smsService = smsService; _voiceCallService = voiceCallService; _usageTracker = usageTracker; _environment = environment; _deviceService = deviceService; _deviceSettingsService = deviceSettingsService; _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; AddAuditLog(currentAdminId, "User.SetRole", id.ToString(), JsonSerializer.Serialize(new { role = role.ToString(), roleId = (int)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; AddAuditLog(currentAdminId, isEnabled ? "User.Enable" : "User.Disable", id.ToString()); 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, NeshanLocation = string.IsNullOrWhiteSpace(device.NeshanLocation) ? null : device.NeshanLocation, Latitude = settings?.Latitude, Longitude = settings?.Longitude, MinimumSmsIntervalMinutes = settings?.MinimumSmsIntervalMinutes, MinimumCallIntervalMinutes = settings?.MinimumCallIntervalMinutes, AreaSquareMeters = settings?.AreaSquareMeters, SimCardType = settings?.SimCardType, SmsRecievers = settings?.SmsRecievers, LastTelemetry = _mapper.Map>(lastTelemetry) }; } // ==================== Devices: create / settings / members ==================== public async Task CreateDeviceAsync(AdminDeviceCreateRequest request, int currentAdminId, CancellationToken cancellationToken) { var deviceName = request.DeviceName?.Trim() ?? string.Empty; if (deviceName.Length == 0 || deviceName.Length > 10) return new AdminDeviceCreateResult { Outcome = AdminDeviceCreateOutcome.Validation, Message = "نام دستگاه باید بین ۱ تا ۱۰ کاراکتر باشد" }; var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); if (user == null) return new AdminDeviceCreateResult { Outcome = AdminDeviceCreateOutcome.UserNotFound }; if (await _dbContext.Devices.AnyAsync(d => d.DeviceName == deviceName, cancellationToken)) return new AdminDeviceCreateResult { Outcome = AdminDeviceCreateOutcome.ConflictDuplicateName, Message = $"دستگاهی با نام «{deviceName}» از قبل وجود دارد" }; var deviceId = await _deviceService.AddDeviceAsync(new DeviceDto { DeviceName = deviceName, UserId = request.UserId, Location = request.Location?.Trim() ?? string.Empty, NeshanLocation = request.NeshanLocation?.Trim() ?? string.Empty }, cancellationToken); // default settings (mirrors the app's default device-settings shape) await _deviceSettingsService.CreateAsync(new DeviceSettingsDto { DeviceId = deviceId, DeviceName = deviceName, MinimumSmsIntervalMinutes = 15, MinimumCallIntervalMinutes = 60, UploadIntervalMin = 5, RelayCount = 4, Relays = Enumerable.Range(1, 4).Select(i => new DeviceRelayDto { RelayIndex = i, Name = string.Empty, IsEnabled = true, RelayKind = Domain.RelayKind.Generic, RuleGroups = new List() }).ToList() }, cancellationToken); AddAuditLog(currentAdminId, "Device.Create", deviceId.ToString(), JsonSerializer.Serialize(new { userId = request.UserId, deviceName })); await _dbContext.SaveChangesAsync(cancellationToken); return new AdminDeviceCreateResult { Outcome = AdminDeviceCreateOutcome.Ok, DeviceId = deviceId, DeviceName = deviceName }; } public async Task UpdateDeviceSettingsAsync(int deviceId, AdminDeviceSettingsUpdateRequest request, int currentAdminId, CancellationToken cancellationToken) { var device = await _dbContext.Devices .Include(d => d.User) .FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken); if (device == null) return new AdminSettingsUpdateResult { NotFound = true }; var deviceName = request.DeviceName?.Trim() ?? string.Empty; if (deviceName.Length == 0 || deviceName.Length > 10) return new AdminSettingsUpdateResult { Message = "نام دستگاه باید بین ۱ تا ۱۰ کاراکتر باشد" }; if (await _dbContext.Devices.AnyAsync(d => d.DeviceName == deviceName && d.Id != deviceId, cancellationToken)) return new AdminSettingsUpdateResult { Message = $"دستگاهی با نام «{deviceName}» از قبل وجود دارد" }; if (request.MinimumSmsIntervalMinutes < 1 || request.MinimumCallIntervalMinutes < 1 || request.UploadIntervalMin < 1) return new AdminSettingsUpdateResult { Message = "فاصله‌های زمانی باید بزرگ‌تر یا مساوی ۱ دقیقه باشند" }; if (request.RelayCount < 1 || request.RelayCount > 16) return new AdminSettingsUpdateResult { Message = "تعداد رله باید بین ۱ تا ۱۶ باشد" }; // keep existing location on the device entity (not part of this form) var settings = await _dbContext.DeviceSettings .Include(s => s.Relays) .ThenInclude(r => r.RuleGroups) .ThenInclude(g => g.Conditions) .FirstOrDefaultAsync(s => s.DeviceId == deviceId, cancellationToken); var dto = new DeviceSettingsDto { DeviceId = deviceId, DeviceName = deviceName, Province = request.Province?.Trim() ?? string.Empty, City = request.City?.Trim() ?? string.Empty, Latitude = request.Latitude, Longitude = request.Longitude, ProductType = request.ProductType?.Trim() ?? string.Empty, MinimumSmsIntervalMinutes = request.MinimumSmsIntervalMinutes, MinimumCallIntervalMinutes = request.MinimumCallIntervalMinutes, AreaSquareMeters = request.AreaSquareMeters, UploadIntervalMin = request.UploadIntervalMin, DevicePhoneNumber = request.DevicePhoneNumber?.Trim() ?? string.Empty, SimCardType = request.SimCardType, RelayCount = request.RelayCount, SmsRecievers = request.SmsRecievers?.Trim() ?? string.Empty }; if (settings == null) { dto.Id = 0; dto.CreatedAt = DateTime.UtcNow; dto.UpdatedAt = DateTime.UtcNow; dto.Relays = Enumerable.Range(1, request.RelayCount).Select(i => new DeviceRelayDto { RelayIndex = i, Name = string.Empty, IsEnabled = true, RelayKind = Domain.RelayKind.Generic, RuleGroups = new List() }).ToList(); await _deviceSettingsService.CreateAsync(dto, cancellationToken); } else { // keep existing relays so SyncRelays only adjusts count without wiping rules dto.Id = settings.Id; dto.Relays = settings.Relays .OrderBy(r => r.RelayIndex) .Select(r => new DeviceRelayDto { Id = r.Id, DeviceSettingsId = settings.Id, RelayIndex = r.RelayIndex, Name = r.Name, IsEnabled = r.IsEnabled, RelayKind = r.RelayKind, RuleGroups = r.RuleGroups .OrderBy(g => g.Order) .Select(g => new DeviceRelayRuleGroupDto { Id = g.Id, Action = g.Action, Label = g.Label, Order = g.Order, Conditions = g.Conditions .OrderBy(c => c.Order) .Select(c => new DeviceRelayRuleConditionDto { Id = c.Id, ConditionType = c.ConditionType, SensorType = c.SensorType, ComparisonType = c.ComparisonType, Value1 = c.Value1, Value2 = c.Value2, StartMinutes = c.StartMinutes, EndMinutes = c.EndMinutes, Order = c.Order }).ToList() }).ToList() }).ToList(); await _deviceSettingsService.UpdateAsync(dto, cancellationToken); } AddAuditLog(currentAdminId, "Device.Update", deviceId.ToString(), JsonSerializer.Serialize(new { deviceName, relayCount = request.RelayCount, smsInterval = request.MinimumSmsIntervalMinutes, callInterval = request.MinimumCallIntervalMinutes, uploadInterval = request.UploadIntervalMin })); await _dbContext.SaveChangesAsync(cancellationToken); return new AdminSettingsUpdateResult(); } public async Task?> GetDeviceMembersAsync(int deviceId, CancellationToken cancellationToken) { var device = await _dbContext.Devices.AsNoTracking() .Include(d => d.User) .FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken); if (device == null) return null; var members = await _dbContext.DeviceUsers.AsNoTracking() .Include(du => du.User) .Where(du => du.DeviceId == deviceId) .OrderBy(du => du.Role) .ThenBy(du => du.UserId) .ToListAsync(cancellationToken); var result = new List(members.Count + 1); // owner row first (owner is NOT in DeviceUsers) result.Add(new AdminDeviceMemberDto { UserId = device.User.Id, Name = device.User.Name, Family = device.User.Family, Mobile = device.User.Mobile, MemberName = device.User.Name, MemberFamily = device.User.Family, Role = Domain.DeviceMemberRole.Manager, ReceiveAlerts = true, AvatarPath = null, IsOwner = true }); result.AddRange(members.Select(du => new AdminDeviceMemberDto { UserId = du.UserId, Name = du.User.Name, Family = du.User.Family, Mobile = du.User.Mobile, MemberName = string.IsNullOrWhiteSpace(du.Name) ? du.User.Name : du.Name, MemberFamily = string.IsNullOrWhiteSpace(du.Family) ? du.User.Family : du.Family, Role = du.Role, ReceiveAlerts = du.ReceiveAlerts, AvatarPath = du.AvatarPath, IsOwner = false })); return result; } // ==================== 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(); } // ==================== Alerts ==================== public async Task> GetAlertsAsync(AdminAlertFilter filter, CancellationToken cancellationToken) { if (filter.Page <= 0) filter.Page = 1; if (filter.PageSize <= 0) filter.PageSize = 20; IQueryable query = _dbContext.AlertLogs.AsNoTracking() .Include(a => a.Device).ThenInclude(d => d.User); if (filter.DeviceId.HasValue) query = query.Where(a => a.DeviceId == filter.DeviceId.Value); if (filter.Status.HasValue) query = query.Where(a => a.Status == filter.Status.Value); if (filter.From.HasValue) query = query.Where(a => a.SentAt >= filter.From.Value); if (filter.To.HasValue) query = query.Where(a => a.SentAt <= filter.To.Value); var totalCount = await query.CountAsync(cancellationToken); var items = await query .OrderByDescending(a => a.SentAt) .ThenByDescending(a => a.Id) .Skip((filter.Page - 1) * filter.PageSize) .Take(filter.PageSize) .ToListAsync(cancellationToken); return new PagedResult { Items = items.Select(a => new AdminAlertItemDto { Id = a.Id, DeviceId = a.DeviceId, DeviceName = a.Device.DeviceName, OwnerName = a.Device.User.Name + " " + a.Device.User.Family, Message = a.Message, NotificationType = a.NotificationType, Status = a.Status, ErrorMessage = a.ErrorMessage, SentAt = a.SentAt }).ToList(), TotalCount = totalCount, Page = filter.Page, PageSize = filter.PageSize }; } // ==================== Posts ==================== public async Task> GetPostsAsync(AdminPostFilter filter, CancellationToken cancellationToken) { if (filter.Page <= 0) filter.Page = 1; if (filter.PageSize <= 0) filter.PageSize = 20; IQueryable query = _dbContext.DevicePosts.AsNoTracking() .Include(p => p.Device) .Include(p => p.AuthorUser) .Include(p => p.Images); if (filter.DeviceId.HasValue) query = query.Where(p => p.DeviceId == filter.DeviceId.Value); if (filter.AuthorUserId.HasValue) query = query.Where(p => p.AuthorUserId == filter.AuthorUserId.Value); if (filter.PostType.HasValue) query = query.Where(p => p.PostType == filter.PostType.Value); if (filter.From.HasValue) query = query.Where(p => p.CreatedAt >= filter.From.Value); if (filter.To.HasValue) query = query.Where(p => p.CreatedAt <= filter.To.Value); var totalCount = await query.CountAsync(cancellationToken); var items = await query .OrderByDescending(p => p.CreatedAt) .ThenByDescending(p => p.Id) .Skip((filter.Page - 1) * filter.PageSize) .Take(filter.PageSize) .ToListAsync(cancellationToken); return new PagedResult { // ponytail: like/reply count intentionally omitted — DevicePost has no such fields yet. Items = items.Select(p => new AdminPostItemDto { Id = p.Id, DeviceId = p.DeviceId, DeviceName = p.Device.DeviceName, AuthorUserId = p.AuthorUserId, AuthorName = p.AuthorUser.Name + " " + p.AuthorUser.Family, Content = p.Content, PostType = p.PostType, ImageCount = p.Images.Count, HasVoice = !string.IsNullOrWhiteSpace(p.VoiceFilePath), VoiceDurationSeconds = p.VoiceDurationSeconds, CreatedAt = p.CreatedAt }).ToList(), TotalCount = totalCount, Page = filter.Page, PageSize = filter.PageSize }; } public async Task GetPostAsync(int id, CancellationToken cancellationToken) { var post = await _dbContext.DevicePosts.AsNoTracking() .Include(p => p.Device) .Include(p => p.AuthorUser) .Include(p => p.Images) .FirstOrDefaultAsync(p => p.Id == id, cancellationToken); if (post == null) return null; return new AdminPostDetailDto { Id = post.Id, DeviceId = post.DeviceId, DeviceName = post.Device.DeviceName, AuthorName = post.AuthorUser.Name, AuthorFamily = post.AuthorUser.Family, PostType = post.PostType, Content = post.Content, CreatedAt = post.CreatedAt, Images = post.Images.Select(i => new AdminPostImageDto { Id = i.Id, FileName = i.FileName, FilePath = i.FilePath }).ToList(), VoiceFilePath = post.VoiceFilePath, VoiceDurationSeconds = post.VoiceDurationSeconds }; } public async Task DeletePostAsync(int id, int currentAdminId, CancellationToken cancellationToken) { var post = await _dbContext.DevicePosts .Include(p => p.Images) .FirstOrDefaultAsync(p => p.Id == id, cancellationToken); if (post == null) return false; // remove image files from disc (best effort — a missing file must not block the delete) var webRoot = _environment.WebRootPath ?? "wwwroot"; foreach (var image in post.Images) { try { var fullPath = Path.Combine(webRoot, image.FilePath.TrimStart('/')); if (File.Exists(fullPath)) File.Delete(fullPath); } catch { // file cleanup is best-effort } } if (!string.IsNullOrWhiteSpace(post.VoiceFilePath)) { try { var voicePath = Path.Combine(webRoot, post.VoiceFilePath.TrimStart('/')); if (File.Exists(voicePath)) File.Delete(voicePath); } catch { // file cleanup is best-effort } } AddAuditLog(currentAdminId, "Post.Delete", id.ToString()); _dbContext.DevicePosts.Remove(post); await _dbContext.SaveChangesAsync(cancellationToken); return true; } // ==================== Device Debug ==================== public async Task TestDeviceSmsAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken) { var device = await _dbContext.Devices.AsNoTracking() .Include(d => d.User) .FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken); if (device == null) return new AdminDeviceTestResultDto { Success = false, Message = "دستگاه یافت نشد" }; var mobile = MobileNormalizer.Normalize(device.User.Mobile); if (mobile.Length == 0) return new AdminDeviceTestResultDto { Success = false, Message = "شماره موبایل مالک معتبر نیست" }; // persist the audit row up-front so it is recorded no matter how the send attempt ends AddAuditLog(currentAdminId, "Device.TestSms", deviceId.ToString(), JsonSerializer.Serialize(new { mobile })); await _dbContext.SaveChangesAsync(cancellationToken); try { var result = await _smsService.SendWebserviceSmsAsync(new WebserviceSmsRequest { Recipient = mobile, Message = $"پیامک تست GreenHome برای دستگاه {device.DeviceName} — {DateTime.UtcNow:yyyy/MM/dd HH:mm} UTC" }, cancellationToken); if (result?.Meta.Status == true) { await _usageTracker.RecordAsync(device.User.Id, UsageKind.Sms, cancellationToken: cancellationToken); return new AdminDeviceTestResultDto { Success = true, Message = $"پیامک تست برای {mobile} ارسال شد" }; } var reason = result?.Meta.Message ?? "خطای نامشخص سرویس پیامک"; return new AdminDeviceTestResultDto { Success = false, Message = reason }; } catch (Exception ex) { return new AdminDeviceTestResultDto { Success = false, Message = $"خطا در ارسال: {ex.Message}" }; } } public async Task TestDeviceVoiceAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken) { var device = await _dbContext.Devices.AsNoTracking() .Include(d => d.User) .FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken); if (device == null) return new AdminDeviceTestResultDto { Success = false, Message = "دستگاه یافت نشد" }; var mobile = MobileNormalizer.Normalize(device.User.Mobile); if (mobile.Length == 0) return new AdminDeviceTestResultDto { Success = false, Message = "شماره موبایل مالک معتبر نیست" }; // persist the audit row up-front so it is recorded no matter how the send attempt ends AddAuditLog(currentAdminId, "Device.TestVoice", deviceId.ToString(), JsonSerializer.Serialize(new { mobile })); await _dbContext.SaveChangesAsync(cancellationToken); try { var result = await _voiceCallService.QuickSendWithTTSAsync(new QuickSendWithTTSRequest { Text = $"پیام صوتی تست از پنل مدیریت گری‌هوم برای دستگاه {device.DeviceName}", Number = mobile, ServerID = 0 }, cancellationToken); if (result?.IsSuccess == true) { // ponytail: ثبت مصرف فقط وقتی call واقعی ارسال می‌شود (هماهنگ با AlertNotificationSender) await _usageTracker.RecordAsync(device.User.Id, UsageKind.VoiceCall, cancellationToken: cancellationToken); return new AdminDeviceTestResultDto { Success = true, Message = $"تماس صوتی تست برای {mobile} انجام شد" }; } var reason = result == null ? "پاسخی از سرویس صوتی دریافت نشد" : $"کد خطا: {result.ReturnValue}"; return new AdminDeviceTestResultDto { Success = false, Message = reason }; } catch (Exception ex) { return new AdminDeviceTestResultDto { Success = false, Message = $"خطا در تماس صوتی: {ex.Message}" }; } } // ==================== Reports ==================== public async Task> GetUserGrowthReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken) { var fromDate = DateOnly.FromDateTime(from.Date); var toDate = DateOnly.FromDateTime(to.Date); var newUsers = await _dbContext.Users.AsNoTracking() .Where(u => DateOnly.FromDateTime(u.CreatedAt) >= fromDate && DateOnly.FromDateTime(u.CreatedAt) <= toDate) .Select(u => new { Date = DateOnly.FromDateTime(u.CreatedAt) }) .GroupBy(x => x.Date) .Select(g => new { Date = g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.Date, x => x.Count, cancellationToken); var activeUsers = await _dbContext.Users.AsNoTracking() .Where(u => u.LastLoginAt != null && DateOnly.FromDateTime(u.LastLoginAt!.Value) >= fromDate && DateOnly.FromDateTime(u.LastLoginAt!.Value) <= toDate) .Select(u => new { Date = DateOnly.FromDateTime(u.LastLoginAt!.Value) }) .GroupBy(x => x.Date) .Select(g => new { Date = g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.Date, x => x.Count, cancellationToken); var rows = new List(); for (var day = fromDate; day <= toDate; day = day.AddDays(1)) { rows.Add(new AdminUserGrowthRowDto { Date = day, NewUsers = newUsers.GetValueOrDefault(day), ActiveUsers = activeUsers.GetValueOrDefault(day) }); } return rows; } public async Task> GetDeviceUptimeReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken) { // ponytail: per-day distinct scan of TelemetryRecords is O(total rows in window); // fine for a few dozen devices — swap for a pre-aggregated daily table if it grows. var fromDate = DateOnly.FromDateTime(from.Date); var toDate = DateOnly.FromDateTime(to.Date); var totalDays = toDate.DayNumber - fromDate.DayNumber + 1; var devices = await _dbContext.Devices.AsNoTracking() .Include(d => d.User) .OrderBy(d => d.DeviceName) .ToListAsync(cancellationToken); var onlineDays = await _dbContext.TelemetryRecords.AsNoTracking() .Where(t => t.ServerTimestampUtc >= from.Date && t.ServerTimestampUtc < to.Date.AddDays(1)) .GroupBy(t => t.DeviceId) .Select(g => new { DeviceId = g.Key, Days = g.Select(t => DateOnly.FromDateTime(t.ServerTimestampUtc)).Distinct().Count() }) .ToDictionaryAsync(x => x.DeviceId, x => x.Days, cancellationToken); return devices.Select(d => { var online = onlineDays.GetValueOrDefault(d.Id, 0); return new AdminDeviceUptimeRowDto { DeviceId = d.Id, DeviceName = d.DeviceName, OwnerName = d.User.Name + " " + d.User.Family, TotalDays = totalDays, OnlineDays = online, OnlinePercent = totalDays > 0 ? (int)Math.Round(online * 100.0 / totalDays) : 0 }; }).ToList(); } public async Task> GetUsageByUserReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken) { var fromDate = DateOnly.FromDateTime(from.Date); var toDate = DateOnly.FromDateTime(to.Date); var rows = await _dbContext.UsageCounters.AsNoTracking() .Where(u => u.Date >= fromDate && u.Date <= toDate) .GroupBy(u => u.UserId) .Select(g => new { UserId = 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) }) .ToListAsync(cancellationToken); var userIds = rows.Select(r => r.UserId).ToList(); var users = await _dbContext.Users.AsNoTracking() .Where(u => userIds.Contains(u.Id)) .ToDictionaryAsync(u => u.Id, cancellationToken); return rows .Where(r => users.ContainsKey(r.UserId)) .OrderByDescending(r => r.Sms + r.Voice + r.AiQueries) .Select(r => new AdminUsageByUserRowDto { UserId = r.UserId, Name = users[r.UserId].Name, Family = users[r.UserId].Family, Mobile = users[r.UserId].Mobile, Sms = r.Sms, VoiceCall = r.Voice, AiQueries = r.AiQueries, AiTokens = r.AiTokens }) .ToList(); } // ==================== Audit Log ==================== public async Task> GetAuditLogsAsync(AdminAuditLogFilter filter, CancellationToken cancellationToken) { if (filter.Page <= 0) filter.Page = 1; if (filter.PageSize <= 0) filter.PageSize = 20; IQueryable query = _dbContext.AdminAuditLogs.AsNoTracking() .Include(a => a.AdminUser); if (filter.AdminId.HasValue) query = query.Where(a => a.AdminUserId == filter.AdminId.Value); var totalCount = await query.CountAsync(cancellationToken); var items = await query .OrderByDescending(a => a.At) .ThenByDescending(a => a.Id) .Skip((filter.Page - 1) * filter.PageSize) .Take(filter.PageSize) .ToListAsync(cancellationToken); return new PagedResult { Items = items.Select(a => new AdminAuditLogDto { Id = a.Id, AdminUserId = a.AdminUserId, AdminName = a.AdminUser.Name + " " + a.AdminUser.Family, Action = a.Action, TargetId = a.TargetId, Details = a.Details, At = a.At }).ToList(), TotalCount = totalCount, Page = filter.Page, PageSize = filter.PageSize }; } // ==================== Helpers ==================== /// /// یک ردیف audit log به change tracker اضافه می‌کند؛ با همان SaveChanges عملیات اصلی ذخیره می‌شود. /// private void AddAuditLog(int adminUserId, string action, string targetId, string details = "") { _dbContext.AdminAuditLogs.Add(new Domain.AdminAuditLog { AdminUserId = adminUserId, Action = action, TargetId = targetId, Details = details, At = DateTime.UtcNow }); } /// /// آخرین تلیمتری هر دستگاه + نام مالک (برای لیست‌های admin). /// 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 }; } }