Files
GreenHomeBack/src/GreenHome.Infrastructure/Services/Admin/AdminService.cs
alireza 69c0381530 ✨ feat(admin): add admin device, post, and report features
- 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
2026-09-25 18:39:52 +03:30

1083 lines
45 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
};
}
}