✨ 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:
@@ -1,3 +1,4 @@
|
||||
using System.Text;
|
||||
using GreenHome.Api.Filters;
|
||||
using GreenHome.Application;
|
||||
using GreenHome.Application.Dtos.Admin;
|
||||
@@ -111,6 +112,44 @@ public sealed class AdminController : ControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("devices")]
|
||||
public async Task<IActionResult> CreateDevice([FromBody] AdminDeviceCreateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||
var result = await _adminService.CreateDeviceAsync(request, currentAdminId, cancellationToken);
|
||||
return result.Outcome switch
|
||||
{
|
||||
AdminDeviceCreateOutcome.Ok => Ok(new { id = result.DeviceId, deviceName = result.DeviceName }),
|
||||
AdminDeviceCreateOutcome.UserNotFound => NotFound(new { message = "کاربر مورد نظر یافت نشد" }),
|
||||
AdminDeviceCreateOutcome.ConflictDuplicateName => Conflict(new { message = result.Message }),
|
||||
_ => BadRequest(new { message = result.Message })
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPut("devices/{id:int}/settings")]
|
||||
public async Task<IActionResult> UpdateDeviceSettings(int id, [FromBody] AdminDeviceSettingsUpdateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||
var result = await _adminService.UpdateDeviceSettingsAsync(id, request, currentAdminId, cancellationToken);
|
||||
if (result.NotFound) return NotFound(new { message = "دستگاه یافت نشد" });
|
||||
if (result.Message is not null) return BadRequest(new { message = result.Message });
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpGet("devices/{id:int}/settings")]
|
||||
public async Task<ActionResult<AdminDeviceDetailDto>> GetDeviceSettings(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _adminService.GetDeviceAsync(id, cancellationToken);
|
||||
return result is null ? NotFound(new { message = "دستگاه یافت نشد" }) : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("devices/{id:int}/members")]
|
||||
public async Task<ActionResult<IReadOnlyList<AdminDeviceMemberDto>>> GetDeviceMembers(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _adminService.GetDeviceMembersAsync(id, cancellationToken);
|
||||
return result is null ? NotFound(new { message = "دستگاه یافت نشد" }) : Ok(result);
|
||||
}
|
||||
|
||||
// ==================== Usage ====================
|
||||
|
||||
[HttpGet("usage")]
|
||||
@@ -155,6 +194,10 @@ public sealed class AdminController : ControllerBase
|
||||
[HttpGet("posts")]
|
||||
public async Task<ActionResult<PagedResult<AdminPostItemDto>>> Posts(
|
||||
[FromQuery] int? deviceId,
|
||||
[FromQuery] int? authorUserId,
|
||||
[FromQuery] Domain.DevicePostType? postType,
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -162,6 +205,10 @@ public sealed class AdminController : ControllerBase
|
||||
var filter = new AdminPostFilter
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
AuthorUserId = authorUserId,
|
||||
PostType = postType,
|
||||
From = from,
|
||||
To = to,
|
||||
Page = Math.Max(1, page),
|
||||
PageSize = Math.Clamp(pageSize, 1, 100)
|
||||
};
|
||||
@@ -170,6 +217,13 @@ public sealed class AdminController : ControllerBase
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("posts/{id:int}")]
|
||||
public async Task<ActionResult<AdminPostDetailDto>> GetPost(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _adminService.GetPostAsync(id, cancellationToken);
|
||||
return result is null ? NotFound(new { message = "پست یافت نشد" }) : Ok(result);
|
||||
}
|
||||
|
||||
[HttpDelete("posts/{id:int}")]
|
||||
public async Task<IActionResult> DeletePost(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -196,6 +250,73 @@ public sealed class AdminController : ControllerBase
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ==================== Reports ====================
|
||||
|
||||
[HttpGet("reports/users")]
|
||||
public async Task<IActionResult> ReportUsers(
|
||||
[FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] bool csv, CancellationToken cancellationToken)
|
||||
{
|
||||
var (f, t) = ResolveReportRange(from, to);
|
||||
var rows = await _adminService.GetUserGrowthReportAsync(f, t, cancellationToken);
|
||||
if (csv)
|
||||
return ToCsvFile("report-users",
|
||||
new[] { "تاریخ", "کاربران جدید", "کاربران فعال" },
|
||||
rows.Select(r => new[] { r.Date.ToString("yyyy-MM-dd"), r.NewUsers.ToString(), r.ActiveUsers.ToString() }), t);
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpGet("reports/devices-uptime")]
|
||||
public async Task<IActionResult> ReportDeviceUptime(
|
||||
[FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] bool csv, CancellationToken cancellationToken)
|
||||
{
|
||||
var (f, t) = ResolveReportRange(from, to);
|
||||
var rows = await _adminService.GetDeviceUptimeReportAsync(f, t, cancellationToken);
|
||||
if (csv)
|
||||
return ToCsvFile("report-devices-uptime",
|
||||
new[] { "دستگاه", "مالک", "کل روزها", "روزهای آنلاین", "درصد آنلاین" },
|
||||
rows.Select(r => new[] { r.DeviceName, r.OwnerName, r.TotalDays.ToString(), r.OnlineDays.ToString(), r.OnlinePercent.ToString() }), t);
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpGet("reports/usage-by-user")]
|
||||
public async Task<IActionResult> ReportUsageByUser(
|
||||
[FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] bool csv, CancellationToken cancellationToken)
|
||||
{
|
||||
var (f, t) = ResolveReportRange(from, to);
|
||||
var rows = await _adminService.GetUsageByUserReportAsync(f, t, cancellationToken);
|
||||
if (csv)
|
||||
return ToCsvFile("report-usage-by-user",
|
||||
new[] { "نام", "نام خانوادگی", "موبایل", "پیامک", "تماس صوتی", "درخواست هوش مصنوعی", "توکن هوش مصنوعی" },
|
||||
rows.Select(r => new[] { r.Name, r.Family, r.Mobile, r.Sms.ToString(), r.VoiceCall.ToString(), r.AiQueries.ToString(), r.AiTokens.ToString() }), t);
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
private static (DateTime From, DateTime To) ResolveReportRange(DateTime? from, DateTime? to)
|
||||
{
|
||||
var t = (to ?? DateTime.Today).Date;
|
||||
var f = (from ?? t.AddDays(-6)).Date;
|
||||
return (f, t);
|
||||
}
|
||||
|
||||
// ponytail: CSV via StringBuilder — fine at report size; swap for a streaming writer if reports grow.
|
||||
private static FileContentResult ToCsvFile(string name, string[] headers, IEnumerable<string[]> rows, DateTime reportDate)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append('\uFEFF'); // UTF-8 BOM so Excel detects Persian correctly
|
||||
sb.Append(string.Join(",", headers.Select(EscapeCsv))).Append('\n');
|
||||
foreach (var row in rows)
|
||||
sb.Append(string.Join(",", row.Select(EscapeCsv))).Append('\n');
|
||||
var result = new FileContentResult(Encoding.UTF8.GetBytes(sb.ToString()), "text/csv; charset=utf-8");
|
||||
result.FileDownloadName = $"{name}-{reportDate:yyyy-MM-dd}.csv";
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string EscapeCsv(string? value)
|
||||
{
|
||||
var v = value ?? string.Empty;
|
||||
return v.Contains(',') || v.Contains('"') || v.Contains('\n') ? $"\"{v.Replace("\"", "\"\"")}\"" : v;
|
||||
}
|
||||
|
||||
// ==================== Audit Log ====================
|
||||
|
||||
[HttpGet("audit-logs")]
|
||||
|
||||
Reference in New Issue
Block a user