✨ 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
This commit is contained in:
2026-09-25 18:39:52 +03:30
parent 6abb5aae15
commit 69c0381530
13 changed files with 1847 additions and 62 deletions

View File

@@ -21,6 +21,8 @@ public sealed class AdminService : IAdminService
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(
@@ -30,7 +32,9 @@ public sealed class AdminService : IAdminService
ISmsService smsService,
IVoiceCallService voiceCallService,
UsageTracker usageTracker,
IWebHostEnvironment environment)
IWebHostEnvironment environment,
IDeviceService deviceService,
IDeviceSettingsService deviceSettingsService)
{
_dbContext = dbContext;
_mapper = mapper;
@@ -38,6 +42,8 @@ public sealed class AdminService : IAdminService
_voiceCallService = voiceCallService;
_usageTracker = usageTracker;
_environment = environment;
_deviceService = deviceService;
_deviceSettingsService = deviceSettingsService;
_onlineWindowMinutes = configuration.GetValue("Admin:OnlineWindowMinutes", 15);
}
@@ -301,10 +307,237 @@ public sealed class AdminService : IAdminService
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)
@@ -404,6 +637,14 @@ public sealed class AdminService : IAdminService
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);
@@ -422,10 +663,13 @@ public sealed class AdminService : IAdminService
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,
@@ -434,6 +678,36 @@ public sealed class AdminService : IAdminService
};
}
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
@@ -559,6 +833,120 @@ public sealed class AdminService : IAdminService
}
}
// ==================== 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)