✨ 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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user