- add admin endpoints to create devices and update device settings - expose device members and post detail for admin management - add post filters by device, author, type, and date range - add user growth, device uptime, and usage reports with CSV export - improve admin layout with sticky header, user info, and logout - add device and author search filters across admin pages
1083 lines
45 KiB
C#
1083 lines
45 KiB
C#
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<AdminDashboardDto> 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<UsageDayDto>(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<PagedResult<AdminUserListItemDto>> GetUsersAsync(AdminUserFilter filter, CancellationToken cancellationToken)
|
||
{
|
||
if (filter.Page <= 0) filter.Page = 1;
|
||
if (filter.PageSize <= 0) filter.PageSize = 20;
|
||
|
||
IQueryable<Domain.User> 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<AdminUserListItemDto>
|
||
{
|
||
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<AdminUserDetailDto?> 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<UserMutationOutcome> 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<UserMutationOutcome> 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<PagedResult<AdminDeviceListItemDto>> GetDevicesAsync(AdminDeviceFilter filter, CancellationToken cancellationToken)
|
||
{
|
||
if (filter.Page <= 0) filter.Page = 1;
|
||
if (filter.PageSize <= 0) filter.PageSize = 20;
|
||
|
||
IQueryable<Domain.Device> 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<AdminDeviceListItemDto>
|
||
{
|
||
Items = items,
|
||
TotalCount = totalCount,
|
||
Page = filter.Page,
|
||
PageSize = filter.PageSize
|
||
};
|
||
}
|
||
|
||
public async Task<AdminDeviceDetailDto?> 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<IReadOnlyList<TelemetryDto>>(lastTelemetry)
|
||
};
|
||
}
|
||
|
||
// ==================== Devices: create / settings / members ====================
|
||
|
||
public async Task<AdminDeviceCreateResult> 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<DeviceRelayRuleGroupDto>()
|
||
}).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<AdminSettingsUpdateResult> 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<DeviceRelayRuleGroupDto>()
|
||
}).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<IReadOnlyList<AdminDeviceMemberDto>?> 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<AdminDeviceMemberDto>(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<IReadOnlyList<UsageDayDto>> 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<Domain.UsageCounter> 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<PagedResult<AdminAlertItemDto>> GetAlertsAsync(AdminAlertFilter filter, CancellationToken cancellationToken)
|
||
{
|
||
if (filter.Page <= 0) filter.Page = 1;
|
||
if (filter.PageSize <= 0) filter.PageSize = 20;
|
||
|
||
IQueryable<Domain.AlertLog> 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<AdminAlertItemDto>
|
||
{
|
||
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<PagedResult<AdminPostItemDto>> GetPostsAsync(AdminPostFilter filter, CancellationToken cancellationToken)
|
||
{
|
||
if (filter.Page <= 0) filter.Page = 1;
|
||
if (filter.PageSize <= 0) filter.PageSize = 20;
|
||
|
||
IQueryable<Domain.DevicePost> 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<AdminPostItemDto>
|
||
{
|
||
// 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<AdminPostDetailDto?> 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<bool> 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<AdminDeviceTestResultDto> 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<AdminDeviceTestResultDto> 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<IReadOnlyList<AdminUserGrowthRowDto>> 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<AdminUserGrowthRowDto>();
|
||
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<IReadOnlyList<AdminDeviceUptimeRowDto>> 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<IReadOnlyList<AdminUsageByUserRowDto>> 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<PagedResult<AdminAuditLogDto>> GetAuditLogsAsync(AdminAuditLogFilter filter, CancellationToken cancellationToken)
|
||
{
|
||
if (filter.Page <= 0) filter.Page = 1;
|
||
if (filter.PageSize <= 0) filter.PageSize = 20;
|
||
|
||
IQueryable<Domain.AdminAuditLog> 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<AdminAuditLogDto>
|
||
{
|
||
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 ====================
|
||
|
||
/// <summary>
|
||
/// یک ردیف audit log به change tracker اضافه میکند؛ با همان SaveChanges عملیات اصلی ذخیره میشود.
|
||
/// </summary>
|
||
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
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// آخرین تلیمتری هر دستگاه + نام مالک (برای لیستهای admin).
|
||
/// </summary>
|
||
private async Task<(Dictionary<int, Domain.TelemetryRecord> Latest, Dictionary<int, string> OwnerNames)> GetDeviceMetaAsync(
|
||
IReadOnlyList<int> deviceIds, CancellationToken cancellationToken)
|
||
{
|
||
if (deviceIds.Count == 0)
|
||
return (new Dictionary<int, Domain.TelemetryRecord>(), new Dictionary<int, string>());
|
||
|
||
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<IReadOnlyList<AdminDeviceListItemDto>> BuildDeviceListAsync(
|
||
IReadOnlyList<int> deviceIds,
|
||
Dictionary<int, Domain.TelemetryRecord> 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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Persian (۰-۹) و Arabic (٠-٩) digits را به آستانهای انگلیسی تبدیل میکند تا search با ارقام فارسی هم کار کند.
|
||
/// (MobileNormalizer برای فرمت یکپارچه موبایل است و برای رشتهی general search قابل استفاده نیست.)
|
||
/// </summary>
|
||
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<int, Domain.TelemetryRecord> 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
|
||
};
|
||
}
|
||
}
|