Skip to content

Commit 35c549c

Browse files
committed
Add admin dashboard with analytics, user management, and org details
- Phase 1: Revenue & Analytics Dashboard - New Analytics page with MRR tracking, user growth/churn, usage trends - MudBlazor charts for visualizing metrics - Revenue analytics API endpoints - Phase 2: User Management Enhancements - Bulk actions: change plan, verify emails, reset usage - Multi-select checkboxes on Users page - Confirmation dialogs for destructive actions - Phase 3: Organization Detail Page - Organization detail view with members, projects, usage - Admin actions: edit org, reset usage, transfer ownership - Link from Organizations list to detail page - Admin pages: Index, Users, UserDetail, Organizations, OrganizationDetail, Health, Logs, Analytics - SuperAdmin authorization policy for all admin endpoints
1 parent d84743b commit 35c549c

22 files changed

Lines changed: 5121 additions & 10 deletions
Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
using LrmCloud.Api.Services;
2+
using LrmCloud.Shared.Api;
3+
using LrmCloud.Shared.DTOs.Admin;
4+
using Microsoft.AspNetCore.Authorization;
5+
using Microsoft.AspNetCore.Mvc;
6+
7+
namespace LrmCloud.Api.Controllers;
8+
9+
/// <summary>
10+
/// Admin endpoints for superadmin users.
11+
/// Provides system statistics, health monitoring, user management, and logs.
12+
/// </summary>
13+
[Route("api/admin")]
14+
[ApiController]
15+
[Authorize(Policy = "SuperAdmin")]
16+
public class AdminController : ApiControllerBase
17+
{
18+
private readonly IAdminService _adminService;
19+
private readonly ILogger<AdminController> _logger;
20+
21+
public AdminController(
22+
IAdminService adminService,
23+
ILogger<AdminController> logger)
24+
{
25+
_adminService = adminService;
26+
_logger = logger;
27+
}
28+
29+
/// <summary>
30+
/// Get database and system statistics.
31+
/// </summary>
32+
[HttpGet("stats")]
33+
[ProducesResponseType(typeof(ApiResponse<AdminStatsDto>), StatusCodes.Status200OK)]
34+
public async Task<ActionResult<ApiResponse<AdminStatsDto>>> GetStats()
35+
{
36+
var stats = await _adminService.GetStatsAsync();
37+
return Success(stats);
38+
}
39+
40+
/// <summary>
41+
/// Get system health status for all services.
42+
/// </summary>
43+
[HttpGet("health")]
44+
[ProducesResponseType(typeof(ApiResponse<SystemHealthDto>), StatusCodes.Status200OK)]
45+
public async Task<ActionResult<ApiResponse<SystemHealthDto>>> GetHealth()
46+
{
47+
var health = await _adminService.GetHealthAsync();
48+
return Success(health);
49+
}
50+
51+
/// <summary>
52+
/// Get application logs with filtering.
53+
/// </summary>
54+
[HttpGet("logs")]
55+
[ProducesResponseType(typeof(ApiResponse<List<LogEntryDto>>), StatusCodes.Status200OK)]
56+
public async Task<ActionResult<ApiResponse<List<LogEntryDto>>>> GetLogs(
57+
[FromQuery] string? level,
58+
[FromQuery] string? search,
59+
[FromQuery] DateTime? from,
60+
[FromQuery] DateTime? to,
61+
[FromQuery] int page = 1,
62+
[FromQuery] int pageSize = 100)
63+
{
64+
if (page < 1) page = 1;
65+
if (pageSize < 1) pageSize = 20;
66+
if (pageSize > 500) pageSize = 500;
67+
68+
var filter = new LogFilterDto
69+
{
70+
Level = level,
71+
Search = search,
72+
From = from,
73+
To = to
74+
};
75+
76+
var logs = await _adminService.GetLogsAsync(filter, page, pageSize);
77+
return Success(logs);
78+
}
79+
80+
/// <summary>
81+
/// Get paginated list of all users.
82+
/// </summary>
83+
[HttpGet("users")]
84+
[ProducesResponseType(typeof(ApiResponse<List<AdminUserDto>>), StatusCodes.Status200OK)]
85+
public async Task<ActionResult<ApiResponse<List<AdminUserDto>>>> GetUsers(
86+
[FromQuery] string? search,
87+
[FromQuery] string? plan,
88+
[FromQuery] int page = 1,
89+
[FromQuery] int pageSize = 20)
90+
{
91+
if (page < 1) page = 1;
92+
if (pageSize < 1) pageSize = 20;
93+
if (pageSize > 100) pageSize = 100;
94+
95+
var (users, totalCount) = await _adminService.GetUsersAsync(search, plan, page, pageSize);
96+
return Paginated(users, page, pageSize, totalCount);
97+
}
98+
99+
/// <summary>
100+
/// Get detailed user information.
101+
/// </summary>
102+
[HttpGet("users/{id:int}")]
103+
[ProducesResponseType(typeof(ApiResponse<AdminUserDetailDto>), StatusCodes.Status200OK)]
104+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
105+
public async Task<ActionResult<ApiResponse<AdminUserDetailDto>>> GetUser(int id)
106+
{
107+
var user = await _adminService.GetUserAsync(id);
108+
if (user == null)
109+
return NotFound(ErrorCodes.RES_NOT_FOUND, "User not found");
110+
111+
return Success(user);
112+
}
113+
114+
/// <summary>
115+
/// Update user's admin-controlled properties.
116+
/// </summary>
117+
[HttpPut("users/{id:int}")]
118+
[ProducesResponseType(typeof(ApiResponse<AdminUserDetailDto>), StatusCodes.Status200OK)]
119+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
120+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
121+
public async Task<ActionResult<ApiResponse<AdminUserDetailDto>>> UpdateUser(int id, [FromBody] AdminUpdateUserDto dto)
122+
{
123+
var (success, errorMessage) = await _adminService.UpdateUserAsync(id, dto);
124+
if (!success)
125+
{
126+
if (errorMessage == "User not found")
127+
return NotFound(ErrorCodes.RES_NOT_FOUND, errorMessage);
128+
return BadRequest(ErrorCodes.VAL_INVALID_INPUT, errorMessage!);
129+
}
130+
131+
// Return updated user
132+
var user = await _adminService.GetUserAsync(id);
133+
return Success(user!);
134+
}
135+
136+
/// <summary>
137+
/// Soft delete a user.
138+
/// </summary>
139+
[HttpDelete("users/{id:int}")]
140+
[ProducesResponseType(StatusCodes.Status204NoContent)]
141+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
142+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
143+
public async Task<IActionResult> DeleteUser(int id)
144+
{
145+
var (success, errorMessage) = await _adminService.DeleteUserAsync(id);
146+
if (!success)
147+
{
148+
if (errorMessage == "User not found")
149+
return NotFound(ErrorCodes.RES_NOT_FOUND, errorMessage);
150+
return BadRequest(ErrorCodes.VAL_INVALID_INPUT, errorMessage!);
151+
}
152+
153+
return NoContent();
154+
}
155+
156+
/// <summary>
157+
/// Reset user's usage counters.
158+
/// </summary>
159+
[HttpPost("users/{id:int}/reset-usage")]
160+
[ProducesResponseType(typeof(ApiResponse<AdminUserDetailDto>), StatusCodes.Status200OK)]
161+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
162+
public async Task<ActionResult<ApiResponse<AdminUserDetailDto>>> ResetUserUsage(int id)
163+
{
164+
var (success, errorMessage) = await _adminService.ResetUserUsageAsync(id);
165+
if (!success)
166+
return NotFound(ErrorCodes.RES_NOT_FOUND, errorMessage!);
167+
168+
// Return updated user
169+
var user = await _adminService.GetUserAsync(id);
170+
return Success(user!);
171+
}
172+
173+
/// <summary>
174+
/// Get paginated list of all organizations.
175+
/// </summary>
176+
[HttpGet("organizations")]
177+
[ProducesResponseType(typeof(ApiResponse<List<AdminOrganizationDto>>), StatusCodes.Status200OK)]
178+
public async Task<ActionResult<ApiResponse<List<AdminOrganizationDto>>>> GetOrganizations(
179+
[FromQuery] string? search,
180+
[FromQuery] int page = 1,
181+
[FromQuery] int pageSize = 20)
182+
{
183+
if (page < 1) page = 1;
184+
if (pageSize < 1) pageSize = 20;
185+
if (pageSize > 100) pageSize = 100;
186+
187+
var (orgs, totalCount) = await _adminService.GetOrganizationsAsync(search, page, pageSize);
188+
return Paginated(orgs, page, pageSize, totalCount);
189+
}
190+
191+
/// <summary>
192+
/// Get recent webhook events.
193+
/// </summary>
194+
[HttpGet("webhook-events")]
195+
[ProducesResponseType(typeof(ApiResponse<List<AdminWebhookEventDto>>), StatusCodes.Status200OK)]
196+
public async Task<ActionResult<ApiResponse<List<AdminWebhookEventDto>>>> GetWebhookEvents(
197+
[FromQuery] int page = 1,
198+
[FromQuery] int pageSize = 50)
199+
{
200+
if (page < 1) page = 1;
201+
if (pageSize < 1) pageSize = 20;
202+
if (pageSize > 200) pageSize = 200;
203+
204+
var (events, totalCount) = await _adminService.GetWebhookEventsAsync(page, pageSize);
205+
return Paginated(events, page, pageSize, totalCount);
206+
}
207+
208+
// ===== Analytics Endpoints =====
209+
210+
/// <summary>
211+
/// Get revenue analytics including MRR and history.
212+
/// </summary>
213+
[HttpGet("analytics/revenue")]
214+
[ProducesResponseType(typeof(ApiResponse<RevenueAnalyticsDto>), StatusCodes.Status200OK)]
215+
public async Task<ActionResult<ApiResponse<RevenueAnalyticsDto>>> GetRevenueAnalytics(
216+
[FromQuery] int months = 12)
217+
{
218+
if (months < 1) months = 1;
219+
if (months > 24) months = 24;
220+
221+
var analytics = await _adminService.GetRevenueAnalyticsAsync(months);
222+
return Success(analytics);
223+
}
224+
225+
/// <summary>
226+
/// Get user analytics including growth, churn, and conversions.
227+
/// </summary>
228+
[HttpGet("analytics/users")]
229+
[ProducesResponseType(typeof(ApiResponse<UserAnalyticsDto>), StatusCodes.Status200OK)]
230+
public async Task<ActionResult<ApiResponse<UserAnalyticsDto>>> GetUserAnalytics(
231+
[FromQuery] int months = 12)
232+
{
233+
if (months < 1) months = 1;
234+
if (months > 24) months = 24;
235+
236+
var analytics = await _adminService.GetUserAnalyticsAsync(months);
237+
return Success(analytics);
238+
}
239+
240+
/// <summary>
241+
/// Get usage analytics including translation character trends.
242+
/// </summary>
243+
[HttpGet("analytics/usage")]
244+
[ProducesResponseType(typeof(ApiResponse<UsageAnalyticsDto>), StatusCodes.Status200OK)]
245+
public async Task<ActionResult<ApiResponse<UsageAnalyticsDto>>> GetUsageAnalytics(
246+
[FromQuery] int days = 30)
247+
{
248+
if (days < 1) days = 1;
249+
if (days > 90) days = 90;
250+
251+
var analytics = await _adminService.GetUsageAnalyticsAsync(days);
252+
return Success(analytics);
253+
}
254+
255+
// ===== Bulk Action Endpoints =====
256+
257+
/// <summary>
258+
/// Change plan for multiple users.
259+
/// </summary>
260+
[HttpPost("users/bulk/change-plan")]
261+
[ProducesResponseType(typeof(ApiResponse<BulkActionResult>), StatusCodes.Status200OK)]
262+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
263+
public async Task<ActionResult<ApiResponse<BulkActionResult>>> BulkChangePlan([FromBody] BulkChangePlanRequest request)
264+
{
265+
if (request.UserIds.Count == 0)
266+
return BadRequest(ErrorCodes.VAL_INVALID_INPUT, "No user IDs provided");
267+
268+
if (request.UserIds.Count > 100)
269+
return BadRequest(ErrorCodes.VAL_INVALID_INPUT, "Maximum 100 users per bulk operation");
270+
271+
var result = await _adminService.BulkChangePlanAsync(request);
272+
return Success(result);
273+
}
274+
275+
/// <summary>
276+
/// Verify emails for multiple users.
277+
/// </summary>
278+
[HttpPost("users/bulk/verify-emails")]
279+
[ProducesResponseType(typeof(ApiResponse<BulkActionResult>), StatusCodes.Status200OK)]
280+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
281+
public async Task<ActionResult<ApiResponse<BulkActionResult>>> BulkVerifyEmails([FromBody] BulkActionRequest request)
282+
{
283+
if (request.UserIds.Count == 0)
284+
return BadRequest(ErrorCodes.VAL_INVALID_INPUT, "No user IDs provided");
285+
286+
if (request.UserIds.Count > 100)
287+
return BadRequest(ErrorCodes.VAL_INVALID_INPUT, "Maximum 100 users per bulk operation");
288+
289+
var result = await _adminService.BulkVerifyEmailsAsync(request);
290+
return Success(result);
291+
}
292+
293+
/// <summary>
294+
/// Reset usage counters for multiple users.
295+
/// </summary>
296+
[HttpPost("users/bulk/reset-usage")]
297+
[ProducesResponseType(typeof(ApiResponse<BulkActionResult>), StatusCodes.Status200OK)]
298+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
299+
public async Task<ActionResult<ApiResponse<BulkActionResult>>> BulkResetUsage([FromBody] BulkActionRequest request)
300+
{
301+
if (request.UserIds.Count == 0)
302+
return BadRequest(ErrorCodes.VAL_INVALID_INPUT, "No user IDs provided");
303+
304+
if (request.UserIds.Count > 100)
305+
return BadRequest(ErrorCodes.VAL_INVALID_INPUT, "Maximum 100 users per bulk operation");
306+
307+
var result = await _adminService.BulkResetUsageAsync(request);
308+
return Success(result);
309+
}
310+
311+
// ===== Organization Detail Endpoints =====
312+
313+
/// <summary>
314+
/// Get detailed organization information.
315+
/// </summary>
316+
[HttpGet("organizations/{id:int}")]
317+
[ProducesResponseType(typeof(ApiResponse<AdminOrganizationDetailDto>), StatusCodes.Status200OK)]
318+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
319+
public async Task<ActionResult<ApiResponse<AdminOrganizationDetailDto>>> GetOrganization(int id)
320+
{
321+
var org = await _adminService.GetOrganizationAsync(id);
322+
if (org == null)
323+
return NotFound(ErrorCodes.RES_NOT_FOUND, "Organization not found");
324+
325+
return Success(org);
326+
}
327+
328+
/// <summary>
329+
/// Update organization's admin-controlled properties.
330+
/// </summary>
331+
[HttpPut("organizations/{id:int}")]
332+
[ProducesResponseType(typeof(ApiResponse<AdminOrganizationDetailDto>), StatusCodes.Status200OK)]
333+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
334+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
335+
public async Task<ActionResult<ApiResponse<AdminOrganizationDetailDto>>> UpdateOrganization(int id, [FromBody] AdminUpdateOrganizationDto dto)
336+
{
337+
var (success, errorMessage) = await _adminService.UpdateOrganizationAsync(id, dto);
338+
if (!success)
339+
{
340+
if (errorMessage == "Organization not found")
341+
return NotFound(ErrorCodes.RES_NOT_FOUND, errorMessage);
342+
return BadRequest(ErrorCodes.VAL_INVALID_INPUT, errorMessage!);
343+
}
344+
345+
// Return updated organization
346+
var org = await _adminService.GetOrganizationAsync(id);
347+
return Success(org!);
348+
}
349+
350+
/// <summary>
351+
/// Transfer organization ownership.
352+
/// </summary>
353+
[HttpPost("organizations/{id:int}/transfer-ownership")]
354+
[ProducesResponseType(typeof(ApiResponse<AdminOrganizationDetailDto>), StatusCodes.Status200OK)]
355+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
356+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
357+
public async Task<ActionResult<ApiResponse<AdminOrganizationDetailDto>>> TransferOrganizationOwnership(int id, [FromBody] AdminTransferOwnershipRequest request)
358+
{
359+
var (success, errorMessage) = await _adminService.TransferOrganizationOwnershipAsync(id, request);
360+
if (!success)
361+
{
362+
if (errorMessage == "Organization not found" || errorMessage == "New owner not found")
363+
return NotFound(ErrorCodes.RES_NOT_FOUND, errorMessage);
364+
return BadRequest(ErrorCodes.VAL_INVALID_INPUT, errorMessage!);
365+
}
366+
367+
// Return updated organization
368+
var org = await _adminService.GetOrganizationAsync(id);
369+
return Success(org!);
370+
}
371+
}

0 commit comments

Comments
 (0)