initialize admin panel and necessry pages
This commit is contained in:
399
src/GreenHome.Infrastructure/Services/Admin/AdminService.cs
Normal file
399
src/GreenHome.Infrastructure/Services/Admin/AdminService.cs
Normal file
@@ -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<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;
|
||||
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;
|
||||
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,
|
||||
LastTelemetry = _mapper.Map<IReadOnlyList<TelemetryDto>>(lastTelemetry)
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 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();
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
/// <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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user