Repository: andyninety9/SkincareProductsSalesSystem
Pull request #191: Feature/user statistic
Author: login: andyninety9
Created: 2025-03-28T16:09:13Z | Merged: 2025-03-28T16:09:21Z

## Summary by Sourcery

Add user statistics features for reporting, including top spending users and user retention rate analysis

New Features:
- Implement a new endpoint to retrieve top 10 spending users based on total order value
- Add a user retention rate calculation feature that tracks user engagement over different time periods

Enhancements:
- Extend user repository with methods to calculate spending and retention metrics
- Create new DTOs to support user statistic reporting


Diff:
diff --git a/src/backend/WebService/src/Application/Constant/IConstantMessage.cs b/src/backend/WebService/src/Application/Constant/IConstantMessage.cs
index 753afde..2994bcf 100644
--- a/src/backend/WebService/src/Application/Constant/IConstantMessage.cs
+++ b/src/backend/WebService/src/Application/Constant/IConstantMessage.cs
@@ -107,5 +107,6 @@ public interface IConstantMessage
         public static string GET_USER_SUMMARY_SUCCESS = "User summary report retrieved successfully.";
         public static string GET_USER_AGE_GROUP_SUCCESS = "User by age group report retrieved successfully.";
         public static string GET_USER_LOCATION_SUCCESS = "User by location report retrieved successfully.";
+        public static string GET_SPENDING_USER_SUCCESS = "Top spending users report retrieved successfully.";
     }
 }
diff --git a/src/backend/WebService/src/Application/Features/ReportsService/Queries/GetTopSpendingUserQueryHandler.cs b/src/backend/WebService/src/Application/Features/ReportsService/Queries/GetTopSpendingUserQueryHandler.cs
new file mode 100644
index 0000000..74bc227
--- /dev/null
+++ b/src/backend/WebService/src/Application/Features/ReportsService/Queries/GetTopSpendingUserQueryHandler.cs
@@ -0,0 +1,44 @@
+using Application.Abstractions.Messaging;
+using Application.Common.ResponseModel;
+using Application.Features.ReportsService.Queries.Response;
+using AutoMapper;
+using Domain.DTOs;
+using Domain.Repositories;
+
+namespace Application.Features.ReportsService.Queries
+{
+    public sealed record GetSpendingUserQuery(string FromDate, string ToDate) : IQuery<GetTopSpendingUserResponse>;
+    internal sealed class GetTopSpendingUserQueryHandler : IQueryHandler<GetSpendingUserQuery, GetTopSpendingUserResponse>
+    {
+        private readonly IMapper _mapper;
+        private readonly IUserRepository _userRepository;
+
+        public GetTopSpendingUserQueryHandler(IMapper mapper, IUserRepository userRepository)
+        {
+            _userRepository = userRepository;
+            _mapper = mapper;
+        }
+
+
+        public async Task<Result<GetTopSpendingUserResponse>> Handle(GetSpendingUserQuery request, CancellationToken cancellationToken)
+        {
+            try
+            {
+               var fromDate = DateTime.Parse(request.FromDate);
+                var toDate = DateTime.Parse(request.ToDate);
+
+                var topSpendingUsers = await _userRepository.GetTopSpendingUsersAsync(fromDate, toDate, cancellationToken);
+                GetTopSpendingUserResponse response = new GetTopSpendingUserResponse();
+                response.TopSpendingUsers = topSpendingUsers;
+
+                return Result<GetTopSpendingUserResponse>.Success(response);
+                
+            }
+            catch (Exception e)
+            {
+                return Result<GetTopSpendingUserResponse>.Failure<GetTopSpendingUserResponse>(new Error("GetTopSpendingUserResponse", e.Message));
+            }
+
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/backend/WebService/src/Application/Features/ReportsService/Queries/GetUserRetentionQueryHandler.cs b/src/backend/WebService/src/Application/Features/ReportsService/Queries/GetUserRetentionQueryHandler.cs
new file mode 100644
index 0000000..3ed3f7d
--- /dev/null
+++ b/src/backend/WebService/src/Application/Features/ReportsService/Queries/GetUserRetentionQueryHandler.cs
@@ -0,0 +1,46 @@
+using Application.Abstractions.Messaging;
+using Application.Common.ResponseModel;
+using Application.Features.ReportsService.Queries.Response;
+using AutoMapper;
+using Domain.DTOs;
+using Domain.Repositories;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.ReportsService.Queries
+{
+    public sealed record GetUserRetentionRateQuery() : IQuery<GetUserRetentionRateDto>;
+    internal sealed class GetUserRetentionQueryHandler : IQueryHandler<GetUserRetentionRateQuery, GetUserRetentionRateDto>
+    {
+        private readonly IMapper _mapper;
+        private readonly IUserRepository _userRepository;
+
+        public GetUserRetentionQueryHandler(IMapper mapper, IUserRepository userRepository)
+        {
+            _userRepository = userRepository;
+            _mapper = mapper;
+        }
+
+
+        public async Task<Result<GetUserRetentionRateDto>> Handle(GetUserRetentionRateQuery request, CancellationToken cancellationToken)
+        {
+            try
+            {
+                var userRetentionRate = await _userRepository.GetUserRetentionAsync(cancellationToken);
+                GetUserRetentionRateDto response = new GetUserRetentionRateDto();
+                response = userRetentionRate;
+
+                return Result<GetUserRetentionRateDto>.Success(response);
+
+            }
+            catch (Exception e)
+            {
+                return Result<GetUserRetentionRateDto>.Failure<GetUserRetentionRateDto>(new Error("GetUserRetentionRateDto", e.Message));
+            }
+            
+           
+
+        }
+
+
+    }
+}
\ No newline at end of file
diff --git a/src/backend/WebService/src/Application/Features/ReportsService/Queries/Response/GetTopSpendingUserResponse.cs b/src/backend/WebService/src/Application/Features/ReportsService/Queries/Response/GetTopSpendingUserResponse.cs
new file mode 100644
index 0000000..8769681
--- /dev/null
+++ b/src/backend/WebService/src/Application/Features/ReportsService/Queries/Response/GetTopSpendingUserResponse.cs
@@ -0,0 +1,13 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Domain.DTOs;
+
+namespace Application.Features.ReportsService.Queries.Response
+{
+    public class GetTopSpendingUserResponse
+    {
+        public ListTopSpendingUserDto TopSpendingUsers { get; set; } = new ListTopSpendingUserDto();
+    }
+}
\ No newline at end of file
diff --git a/src/backend/WebService/src/Domain/DTOs/GetTopSpendingUsersDto.cs b/src/backend/WebService/src/Domain/DTOs/GetTopSpendingUsersDto.cs
new file mode 100644
index 0000000..30e9a49
--- /dev/null
+++ b/src/backend/WebService/src/Domain/DTOs/GetTopSpendingUsersDto.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Domain.DTOs
+{
+    public class GetTopSpendingUsersDto
+    {
+        public long UserId { get; set; }
+        public string UserName { get; set; } = string.Empty;
+        public int OrderCount { get; set; }
+        public double TotalSpent { get; set; }
+        public double AvgOrderValue { get; set; }
+    }
+}
\ No newline at end of file
diff --git a/src/backend/WebService/src/Domain/DTOs/GetUserRetentionRateDto.cs b/src/backend/WebService/src/Domain/DTOs/GetUserRetentionRateDto.cs
new file mode 100644
index 0000000..705b40d
--- /dev/null
+++ b/src/backend/WebService/src/Domain/DTOs/GetUserRetentionRateDto.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Domain.DTOs
+{
+    public class GetUserRetentionRateDto
+    {
+        public double After1Month { get; set; }
+        public double After3Month { get; set; }
+        public double After6Month { get; set; }
+        public double After12Month { get; set; }
+    }
+}
\ No newline at end of file
diff --git a/src/backend/WebService/src/Domain/DTOs/ListTopSpendingUserDto.cs b/src/backend/WebService/src/Domain/DTOs/ListTopSpendingUserDto.cs
new file mode 100644
index 0000000..2215e16
--- /dev/null
+++ b/src/backend/WebService/src/Domain/DTOs/ListTopSpendingUserDto.cs
@@ -0,0 +1,12 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Domain.DTOs
+{
+    public class ListTopSpendingUserDto
+    {
+        public List<GetTopSpendingUsersDto> TopSpendingUsers { get; set; } = new List<GetTopSpendingUsersDto>();
+    }
+}
\ No newline at end of file
diff --git a/src/backend/WebService/src/Domain/Repositories/IUserRepository.cs b/src/backend/WebService/src/Domain/Repositories/IUserRepository.cs
index ba55d90..d95e44f 100644
--- a/src/backend/WebService/src/Domain/Repositories/IUserRepository.cs
+++ b/src/backend/WebService/src/Domain/Repositories/IUserRepository.cs
@@ -32,13 +32,18 @@ public IQueryable<User> SearchUsers(string? keyword = null, int? page = null, in
             string? gender = null, int? status = null, int? role = null,
             DateTime? fromDate = null, DateTime? toDate = null);
 
-        public Task<TotalUserDto> GetTotalUserAsync(DateTime fromDate, DateTime toDate,CancellationToken cancellationToken);
-        public Task<NewUserCountDto> GetNewUserCountAsync(DateTime fromDate, DateTime toDate,CancellationToken cancellationToken);
-        public Task<ActiveUserCountDto> GetActiveUserCountAsync(DateTime fromDate, DateTime toDate,CancellationToken cancellationToken);
-        public Task<UserGrowthRateDto> GetUserGrowthRateAsync(DateTime fromDate, DateTime toDate,CancellationToken cancellationToken);
+        public Task<TotalUserDto> GetTotalUserAsync(DateTime fromDate, DateTime toDate, CancellationToken cancellationToken);
+        public Task<NewUserCountDto> GetNewUserCountAsync(DateTime fromDate, DateTime toDate, CancellationToken cancellationToken);
+        public Task<ActiveUserCountDto> GetActiveUserCountAsync(DateTime fromDate, DateTime toDate, CancellationToken cancellationToken);
+        public Task<UserGrowthRateDto> GetUserGrowthRateAsync(DateTime fromDate, DateTime toDate, CancellationToken cancellationToken);
         public Task<UserByAgeGroupDto> GetUserByAgeGroupAsync(CancellationToken cancellationToken);
         public Task<ListUserByLocationDto> GetUserByLocationAsync(DateTime fromDate, DateTime toDate, CancellationToken cancellationToken);
 
+        public Task<ListTopSpendingUserDto> GetTopSpendingUsersAsync(DateTime fromDate, DateTime toDate, CancellationToken cancellationToken);
+        public Task<GetUserRetentionRateDto> GetUserRetentionAsync(CancellationToken cancellationToken);
+        
+
+
 
     }
 }
\ No newline at end of file
diff --git a/src/backend/WebService/src/Infrastructure/Repositories/UserRepository.cs b/src/backend/WebService/src/Infrastructure/Repositories/UserRepository.cs
index bca63ed..1444858 100644
--- a/src/backend/WebService/src/Infrastructure/Repositories/UserRepository.cs
+++ b/src/backend/WebService/src/Infrastructure/Repositories/UserRepository.cs
@@ -70,6 +70,49 @@ public Task<NewUserCountDto> GetNewUserCountAsync(DateTime fromDate, DateTime to
             return Task.FromResult(new NewUserCountDto { NewUserCount = newUserCount });
         }
 
+        public Task<ListTopSpendingUserDto> GetTopSpendingUsersAsync(DateTime fromDate, DateTime toDate, CancellationToken cancellationToken)
+        {
+            try
+            {
+                var topSpendingUsers = _context.Users
+                    .Join(
+                        _context.Orders,
+                        user => user.UsrId,
+                        order => order.UsrId,
+                        (user, order) => new { User = user, Order = order }
+                    )
+                    .Where(joined =>
+                        joined.Order.CreatedAt >= fromDate &&
+                        joined.Order.CreatedAt <= toDate &&
+                        joined.Order.OrdStatusId > 0 &&
+                        joined.Order.IsPaid == true)
+                    .GroupBy(joined => new { joined.User.UsrId, joined.User.Fullname })
+                    .Select(group => new GetTopSpendingUsersDto
+                    {
+                        UserId = group.Key.UsrId,
+                        UserName = group.Key.Fullname ?? "Unknown",
+                        OrderCount = group.Count(),
+                        TotalSpent = group.Sum(item => item.Order.TotalOrdPrice),
+                        AvgOrderValue = Math.Round(
+                            group.Sum(item => item.Order.TotalOrdPrice) / group.Count(),
+                            2)
+                    })
+                    .OrderByDescending(dto => dto.TotalSpent)
+                    .Take(10)
+                    .ToList();
+
+                return Task.FromResult(new ListTopSpendingUserDto
+                {
+                    TopSpendingUsers = topSpendingUsers
+                });
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Error occurred while getting top spending users between {FromDate} and {ToDate}", fromDate, toDate);
+                throw;
+            }
+        }
+
         public Task<TotalUserDto> GetTotalUserAsync(DateTime fromDate, DateTime toDate, CancellationToken cancellationToken)
         {
             var totalUser = _context.Users.Count(u => u.CreatedAt >= fromDate && u.CreatedAt <= toDate);
@@ -225,6 +268,87 @@ public async Task<UserGrowthRateDto> GetUserGrowthRateAsync(DateTime fromDate, D
             }
         }
 
+        public Task<GetUserRetentionRateDto> GetUserRetentionAsync(CancellationToken cancellationToken)
+        {
+            try
+            {
+                // Get first day of previous month
+                DateTime now = DateTime.Now;
+                DateTime firstDayLastMonth = new DateTime(now.Year, now.AddMonths(-1).Month, 1);
+                DateTime firstDayCurrentMonth = new DateTime(now.Year, now.Month, 1);
+
+                // Get users registered last month
+                var registeredUsers = _context.Users
+                    .Where(u => u.CreatedAt >= firstDayLastMonth && u.CreatedAt < firstDayCurrentMonth)
+                    .ToList();
+
+                if (registeredUsers.Count == 0)
+                {
+                    // No users registered last month
+                    return Task.FromResult(new GetUserRetentionRateDto
+                    {
+                        After1Month = 0,
+                        After3Month = 0,
+                        After6Month = 0,
+                        After12Month = 0
+                    });
+                }
+
+                // Count users who placed orders after registration period
+                var userIds = registeredUsers.Select(u => u.UsrId).ToList();
+
+                var ordersWithUsers = _context.Orders
+                    .Where(o => userIds.Contains(o.UsrId))
+                    .AsEnumerable()
+                    .GroupBy(o => o.UsrId)
+                    .ToDictionary(g => g.Key, g => g.ToList());
+
+                int usersAfter1Month = 0;
+                int usersAfter3Months = 0;
+                int usersAfter6Months = 0;
+                int usersAfter12Months = 0;
+
+                foreach (var user in registeredUsers)
+                {
+                    if (!ordersWithUsers.TryGetValue(user.UsrId, out var userOrders))
+                        continue;
+
+                    // Calculate retention for each period
+                    if (userOrders.Any(o => o.CreatedAt >= user.CreatedAt.AddMonths(1)))
+                        usersAfter1Month++;
+
+                    if (userOrders.Any(o => o.CreatedAt >= user.CreatedAt.AddMonths(3)))
+                        usersAfter3Months++;
+
+                    if (userOrders.Any(o => o.CreatedAt >= user.CreatedAt.AddMonths(6)))
+                        usersAfter6Months++;
+
+                    if (userOrders.Any(o => o.CreatedAt >= user.CreatedAt.AddMonths(12)))
+                        usersAfter12Months++;
+                }
+
+                // Calculate retention rates
+                decimal totalUsers = registeredUsers.Count;
+                decimal retention1Month = Math.Round((usersAfter1Month / totalUsers) * 100, 2);
+                decimal retention3Month = Math.Round((usersAfter3Months / totalUsers) * 100, 2);
+                decimal retention6Month = Math.Round((usersAfter6Months / totalUsers) * 100, 2);
+                decimal retention12Month = Math.Round((usersAfter12Months / totalUsers) * 100, 2);
+
+                return Task.FromResult(new GetUserRetentionRateDto
+                {
+                    After1Month = (double)retention1Month,
+                    After3Month = (double)retention3Month,
+                    After6Month = (double)retention6Month,
+                    After12Month = (double)retention12Month
+                });
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Error occurred while calculating user retention rates");
+                throw;
+            }
+        }
+
         public Task<bool> IsExistedEmail(string email)
         {
             User? user = _context.Users.FirstOrDefault(u => u.Email == email);
diff --git a/src/backend/WebService/src/WebApi/Controllers/Reports/ReportController.cs b/src/backend/WebService/src/WebApi/Controllers/Reports/ReportController.cs
index e8e280b..0a274e2 100644
--- a/src/backend/WebService/src/WebApi/Controllers/Reports/ReportController.cs
+++ b/src/backend/WebService/src/WebApi/Controllers/Reports/ReportController.cs
@@ -140,5 +140,47 @@ public async Task<IActionResult> GetUserByLocation([FromQuery] GetUserByLocation
             var result = await _mediator.Send(request, cancellationToken);
             return result.IsFailure ? HandleFailure(result) : Ok(new { statusCode = 200, message = IConstantMessage.GET_USER_LOCATION_SUCCESS, data = result.Value });
         }
+
+        /// <summary>
+        /// Get spending user report
+        /// </summary>
+        /// <param name="request">Spending user report details.</param>
+        /// <param name="cancellationToken">Cancellation token.</param>
+        /// <returns>Spending user report data.</returns>
+        /// <remarks>
+        /// Sample request:
+        ///
+        ///     GET /api/Report/spending-user?fromtDate=2021-01-01&toDate=2021-12-31
+        ///
+        /// </remarks>
+        [HttpGet("spending-user")]
+        [Authorize]
+        [AuthorizeRole(RoleAccountEnum.Manager)]
+        public async Task<IActionResult> GetSpendingUser([FromQuery] GetSpendingUserQuery request, CancellationToken cancellationToken)
+        {
+            var result = await _mediator.Send(request, cancellationToken);
+            return result.IsFailure ? HandleFailure(result) : Ok(new { statusCode = 200, message = IConstantMessage.GET_SPENDING_USER_SUCCESS, data = result.Value });
+        }
+
+        /// <summary>
+        /// Get User Retention Rate
+        /// </summary>
+        /// <param name="request">User Retention Rate report details.</param>
+        /// <param name="cancellationToken">Cancellation token.</param>
+        /// <returns>User Retention Rate report data.</returns>
+        /// <remarks>
+        /// Sample request:
+        ///
+        ///     GET /api/Report/user-retention-rate?fromtDate=2021-01-01&toDate=2021-12-31
+        ///
+        /// </remarks>
+        [HttpGet("user-retention-rate")]
+        [Authorize]
+        [AuthorizeRole(RoleAccountEnum.Manager)]
+        public async Task<IActionResult> GetUserRetentionRate([FromQuery] GetUserRetentionRateQuery request, CancellationToken cancellationToken)
+        {
+            var result = await _mediator.Send(request, cancellationToken);
+            return result.IsFailure ? HandleFailure(result) : Ok(new { statusCode = 200, message = IConstantMessage.GET_USER_SUMMARY_SUCCESS, data = result.Value });
+        }
     }
 }
\ No newline at end of file


Reviews:
user: login: sourcery-ai[bot]
body: Hey @andyninety9 - I've reviewed your changes - here's some feedback:

**Overall Comments**:
- Consider adding input validation for the `FromDate` and `ToDate` parameters in the `GetSpendingUserQuery` to ensure they are valid date formats.
- The nested try-catch block in `GetUserRetentionAsync` could be simplified to a single try-catch encompassing the entire method.

<details>
<summary>Here's what I looked at during the review</summary>

- 🟡 **General issues**: 4 issues found
- 🟢 **Security**: all looks good
- 🟢 **Testing**: all looks good
- 🟡 **Complexity**: 1 issue found
- 🟢 **Documentation**: all looks good
</details>

***

<details>
<summary>Sourcery is free for open source - if you like our reviews please consider sharing them ✨</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://sourcery.ai)
- [Facebook](https://www.facebook.com/sharer/sharer.php?u=https://sourcery.ai)

</details>

<sub>
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
</sub>
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-28T16:10:19Z

Review comments:
diff_hunk: @@ -70,6 +70,49 @@ public Task<NewUserCountDto> GetNewUserCountAsync(DateTime fromDate, DateTime to
             return Task.FromResult(new NewUserCountDto { NewUserCount = newUserCount });
         }
 
+        public Task<ListTopSpendingUserDto> GetTopSpendingUsersAsync(DateTime fromDate, DateTime toDate, CancellationToken cancellationToken)
+        {
+            try
+            {
+                var topSpendingUsers = _context.Users
+                    .Join(
+                        _context.Orders,
+                        user => user.UsrId,
+                        order => order.UsrId,
+                        (user, order) => new { User = user, Order = order }
+                    )
+                    .Where(joined =>
+                        joined.Order.CreatedAt >= fromDate &&
+                        joined.Order.CreatedAt <= toDate &&
+                        joined.Order.OrdStatusId > 0 &&
+                        joined.Order.IsPaid == true)
+                    .GroupBy(joined => new { joined.User.UsrId, joined.User.Fullname })
+                    .Select(group => new GetTopSpendingUsersDto
+                    {
+                        UserId = group.Key.UsrId,
+                        UserName = group.Key.Fullname ?? "Unknown",
+                        OrderCount = group.Count(),
+                        TotalSpent = group.Sum(item => item.Order.TotalOrdPrice),
+                        AvgOrderValue = Math.Round(
+                            group.Sum(item => item.Order.TotalOrdPrice) / group.Count(),
+                            2)
+                    })
+                    .OrderByDescending(dto => dto.TotalSpent)
+                    .Take(10)
+                    .ToList();
path: src/backend/WebService/src/Infrastructure/Repositories/UserRepository.cs
user: login: sourcery-ai[bot]
body: **suggestion (performance):** Use async query execution in GetTopSpendingUsersAsync.

The repository method executes the query synchronously via ToList(). Consider using an async variant (e.g., ToListAsync(cancellationToken)) to avoid thread blocking and properly support the cancellation token.

Suggested implementation:

```csharp
                    .OrderByDescending(dto => dto.TotalSpent)
                    .Take(10)
                    .ToListAsync(cancellationToken);

```

```csharp
                return new ListTopSpendingUserDto
                {
                    TopSpendingUsers = topSpendingUsers
                };

```

Ensure the following:
1. The method signature for GetTopSpendingUsersAsync is updated to be async (e.g., public async Task<ListTopSpendingUserDto> GetTopSpendingUsersAsync(..., CancellationToken cancellationToken)).
2. Add the required using directive if it’s not yet present:
   using Microsoft.EntityFrameworkCore;
created_at: 2025-03-28T16:10:19Z
author_association: NONE
diff_hunk: @@ -70,6 +70,49 @@ public Task<NewUserCountDto> GetNewUserCountAsync(DateTime fromDate, DateTime to
             return Task.FromResult(new NewUserCountDto { NewUserCount = newUserCount });
         }
 
+        public Task<ListTopSpendingUserDto> GetTopSpendingUsersAsync(DateTime fromDate, DateTime toDate, CancellationToken cancellationToken)
+        {
+            try
+            {
+                var topSpendingUsers = _context.Users
+                    .Join(
+                        _context.Orders,
+                        user => user.UsrId,
+                        order => order.UsrId,
+                        (user, order) => new { User = user, Order = order }
+                    )
+                    .Where(joined =>
+                        joined.Order.CreatedAt >= fromDate &&
+                        joined.Order.CreatedAt <= toDate &&
+                        joined.Order.OrdStatusId > 0 &&
+                        joined.Order.IsPaid == true)
+                    .GroupBy(joined => new { joined.User.UsrId, joined.User.Fullname })
+                    .Select(group => new GetTopSpendingUsersDto
+                    {
+                        UserId = group.Key.UsrId,
+                        UserName = group.Key.Fullname ?? "Unknown",
+                        OrderCount = group.Count(),
+                        TotalSpent = group.Sum(item => item.Order.TotalOrdPrice),
+                        AvgOrderValue = Math.Round(
+                            group.Sum(item => item.Order.TotalOrdPrice) / group.Count(),
+                            2)
+                    })
+                    .OrderByDescending(dto => dto.TotalSpent)
+                    .Take(10)
+                    .ToList();
path: src/backend/WebService/src/Infrastructure/Repositories/UserRepository.cs
user: login: sourcery-ai[bot]
body: **suggestion (performance):** Execute retention query asynchronously.

In GetUserRetentionAsync, consider using an async method (e.g., ToListAsync) when retrieving registered users to prevent blocking and make better use of asynchronous patterns, especially if the number of records is large.

Suggested implementation:

```csharp
        public async Task<GetUserRetentionRateDto> GetUserRetentionAsync(CancellationToken cancellationToken)

```

```csharp
                // Get users registered last month asynchronously
                var registeredUsers = await _context.Users
                    .Where(u => u.CreatedAt >= firstDayLastMonth && u.CreatedAt < firstDayCurrentMonth)
                    .ToListAsync(cancellationToken);

```

Ensure you have the following using directive at the top of the file if it's not already present:
using Microsoft.EntityFrameworkCore;
created_at: 2025-03-28T16:10:19Z
author_association: NONE
diff_hunk: @@ -0,0 +1,44 @@
+using Application.Abstractions.Messaging;
+using Application.Common.ResponseModel;
+using Application.Features.ReportsService.Queries.Response;
+using AutoMapper;
+using Domain.DTOs;
+using Domain.Repositories;
+
+namespace Application.Features.ReportsService.Queries
+{
+    public sealed record GetSpendingUserQuery(string FromDate, string ToDate) : IQuery<GetTopSpendingUserResponse>;
+    internal sealed class GetTopSpendingUserQueryHandler : IQueryHandler<GetSpendingUserQuery, GetTopSpendingUserResponse>
+    {
+        private readonly IMapper _mapper;
+        private readonly IUserRepository _userRepository;
+
+        public GetTopSpendingUserQueryHandler(IMapper mapper, IUserRepository userRepository)
+        {
+            _userRepository = userRepository;
+            _mapper = mapper;
+        }
+
+
+        public async Task<Result<GetTopSpendingUserResponse>> Handle(GetSpendingUserQuery request, CancellationToken cancellationToken)
+        {
+            try
+            {
+               var fromDate = DateTime.Parse(request.FromDate);
+                var toDate = DateTime.Parse(request.ToDate);
path: src/backend/WebService/src/Application/Features/ReportsService/Queries/GetTopSpendingUserQueryHandler.cs
user: login: sourcery-ai[bot]
body: **suggestion (bug_risk):** Validate and parse input date strings safely.

Using DateTime.Parse can throw exceptions if the input strings are invalid. Consider using DateTime.TryParse to provide more robust error handling or return a meaningful failure when date parsing fails.

```suggestion
               if (!DateTime.TryParse(request.FromDate, out var fromDate))
               {
                   return Result<GetTopSpendingUserResponse>.Failure<GetTopSpendingUserResponse>(
                       new Error("GetTopSpendingUserResponse", "Invalid 'FromDate' format."));
               }

               if (!DateTime.TryParse(request.ToDate, out var toDate))
               {
                   return Result<GetTopSpendingUserResponse>.Failure<GetTopSpendingUserResponse>(
                       new Error("GetTopSpendingUserResponse", "Invalid 'ToDate' format."));
               }
```
created_at: 2025-03-28T16:10:19Z
author_association: NONE
diff_hunk: @@ -140,5 +140,47 @@ public async Task<IActionResult> GetUserByLocation([FromQuery] GetUserByLocation
             var result = await _mediator.Send(request, cancellationToken);
             return result.IsFailure ? HandleFailure(result) : Ok(new { statusCode = 200, message = IConstantMessage.GET_USER_LOCATION_SUCCESS, data = result.Value });
         }
+
+        /// <summary>
+        /// Get spending user report
+        /// </summary>
+        /// <param name="request">Spending user report details.</param>
+        /// <param name="cancellationToken">Cancellation token.</param>
+        /// <returns>Spending user report data.</returns>
+        /// <remarks>
+        /// Sample request:
+        ///
+        ///     GET /api/Report/spending-user?fromtDate=2021-01-01&toDate=2021-12-31
+        ///
+        /// </remarks>
+        [HttpGet("spending-user")]
+        [Authorize]
+        [AuthorizeRole(RoleAccountEnum.Manager)]
+        public async Task<IActionResult> GetSpendingUser([FromQuery] GetSpendingUserQuery request, CancellationToken cancellationToken)
+        {
+            var result = await _mediator.Send(request, cancellationToken);
+            return result.IsFailure ? HandleFailure(result) : Ok(new { statusCode = 200, message = IConstantMessage.GET_SPENDING_USER_SUCCESS, data = result.Value });
+        }
+
+        /// <summary>
+        /// Get User Retention Rate
+        /// </summary>
+        /// <param name="request">User Retention Rate report details.</param>
+        /// <param name="cancellationToken">Cancellation token.</param>
+        /// <returns>User Retention Rate report data.</returns>
+        /// <remarks>
+        /// Sample request:
+        ///
+        ///     GET /api/Report/user-retention-rate?fromtDate=2021-01-01&toDate=2021-12-31
path: src/backend/WebService/src/WebApi/Controllers/Reports/ReportController.cs
user: login: sourcery-ai[bot]
body: **suggestion:** Review sample request in user-retention-rate documentation.

The sample request for the user-retention-rate endpoint includes 'fromtDate' and 'toDate' parameters, but the underlying query handler does not take any date parameters. Adjust the documentation to accurately reflect the actual request parameters for clarity.

```suggestion
        ///     GET /api/Report/spending-user
```
created_at: 2025-03-28T16:10:19Z
author_association: NONE
diff_hunk: @@ -70,6 +70,49 @@ public Task<NewUserCountDto> GetNewUserCountAsync(DateTime fromDate, DateTime to
             return Task.FromResult(new NewUserCountDto { NewUserCount = newUserCount });
         }
 
+        public Task<ListTopSpendingUserDto> GetTopSpendingUsersAsync(DateTime fromDate, DateTime toDate, CancellationToken cancellationToken)
path: src/backend/WebService/src/Infrastructure/Repositories/UserRepository.cs
user: login: sourcery-ai[bot]
body: **issue (complexity):** Consider extracting portions of the logic in GetUserRetentionAsync and GetTopSpendingUsersAsync into helper methods to improve readability and reduce nesting complexity, which also makes unit testing easier. 

You can improve the readability of these complex methods by isolating portions of the logic into small helper methods. For example, in **GetUserRetentionAsync** the multiple retention period checks can be abstracted. This not only reduces the nesting but also makes unit testing easier.

**Before refactoring (current loop for each period):**

```csharp
foreach (var user in registeredUsers)
{
    if (!ordersWithUsers.TryGetValue(user.UsrId, out var userOrders))
        continue;

    if (userOrders.Any(o => o.CreatedAt >= user.CreatedAt.AddMonths(1)))
        usersAfter1Month++;

    if (userOrders.Any(o => o.CreatedAt >= user.CreatedAt.AddMonths(3)))
        usersAfter3Months++;

    if (userOrders.Any(o => o.CreatedAt >= user.CreatedAt.AddMonths(6)))
        usersAfter6Months++;

    if (userOrders.Any(o => o.CreatedAt >= user.CreatedAt.AddMonths(12)))
        usersAfter12Months++;
}
```

**Refactoring suggestion:**

1. **Create a helper method** to encapsulate the retention check logic.

2. **Use it within the loop** to simplify the body:

```csharp
private int CheckRetention(IEnumerable<Order> orders, DateTime registrationDate, int months)
{
    DateTime threshold = registrationDate.AddMonths(months);
    return orders.Any(o => o.CreatedAt >= threshold) ? 1 : 0;
}
```

Then refactor the loop as follows:

```csharp
foreach (var user in registeredUsers)
{
    if (!ordersWithUsers.TryGetValue(user.UsrId, out var userOrders))
        continue;

    usersAfter1Month += CheckRetention(userOrders, user.CreatedAt, 1);
    usersAfter3Months += CheckRetention(userOrders, user.CreatedAt, 3);
    usersAfter6Months += CheckRetention(userOrders, user.CreatedAt, 6);
    usersAfter12Months += CheckRetention(userOrders, user.CreatedAt, 12);
}
```

For **GetTopSpendingUsersAsync**, consider extracting parts of the query logic (for example, checking if an order is valid) into a helper to reduce the depth of chained conditions:

```csharp
private bool IsValidOrder(Order order, DateTime fromDate, DateTime toDate)
{
    return order.CreatedAt >= fromDate &&
           order.CreatedAt <= toDate &&
           order.OrdStatusId > 0 &&
           order.IsPaid;
}
```

Then modify the LINQ query like:

```csharp
var topSpendingUsers = _context.Users
    .Join(_context.Orders,
          user => user.UsrId,
          order => order.UsrId,
          (user, order) => new { User = user, Order = order })
    .Where(joined => IsValidOrder(joined.Order, fromDate, toDate))
    .GroupBy(joined => new { joined.User.UsrId, joined.User.Fullname })
    .Select(group => new GetTopSpendingUsersDto
    {
         UserId = group.Key.UsrId,
         UserName = group.Key.Fullname ?? "Unknown",
         OrderCount = group.Count(),
         TotalSpent = group.Sum(item => item.Order.TotalOrdPrice),
         AvgOrderValue = Math.Round(group.Sum(item => item.Order.TotalOrdPrice) / group.Count(), 2)
    })
    .OrderByDescending(dto => dto.TotalSpent)
    .Take(10)
    .ToList();
```

These changes keep the functionality intact while reducing complexity in each method.
created_at: 2025-03-28T16:10:19Z
author_association: NONE

PR comments:
user: login: sourcery-ai[bot]
created_at: 2025-03-28T16:09:19Z
body: <!-- Generated by sourcery-ai[bot]: start review_guide -->

## Reviewer's Guide by Sourcery

This pull request introduces two new features: the ability to retrieve a list of top spending users within a specified date range and the calculation of user retention rates. The top spending users feature calculates the total spent and average order value for each user, orders them by total spending, and returns the top 10. The user retention rate feature calculates retention rates for 1, 3, 6, and 12-month periods based on user order activity after registration.

#### Sequence diagram for GetTopSpendingUsersAsync

```mermaid
sequenceDiagram
    participant Controller
    participant Mediator
    participant UserRepository
    participant DbContext

    Controller->>Mediator: Send GetSpendingUserQuery
    Mediator->>UserRepository: GetTopSpendingUsersAsync(fromDate, toDate, cancellationToken)
    UserRepository->>DbContext: Query Users and Orders
    DbContext-->>UserRepository: Returns top spending users data
    UserRepository-->>Mediator: Returns ListTopSpendingUserDto
    Mediator-->>Controller: Returns Result<GetTopSpendingUserResponse>
    Controller-->>Client: Returns HTTP Response
```

#### Sequence diagram for GetUserRetentionAsync

```mermaid
sequenceDiagram
    participant Controller
    participant Mediator
    participant UserRepository
    participant DbContext

    Controller->>Mediator: Send GetUserRetentionRateQuery
    Mediator->>UserRepository: GetUserRetentionAsync(cancellationToken)
    UserRepository->>DbContext: Query Users and Orders
    DbContext-->>UserRepository: Returns user retention data
    UserRepository-->>Mediator: Returns GetUserRetentionRateDto
    Mediator-->>Controller: Returns Result<GetUserRetentionRateDto>
    Controller-->>Client: Returns HTTP Response
```

#### ER diagram for Top Spending Users

```mermaid
erDiagram
    User {
        long UsrId PK
        string Fullname
    }
    Order {
        long OrdId PK
        long UsrId FK
        DateTime CreatedAt
        double TotalOrdPrice
        int OrdStatusId
        boolean IsPaid
    }
    User ||--o{ Order : places
```

### File-Level Changes

| Change | Details | Files |
| ------ | ------- | ----- |
| Implements the retrieval of top spending users within a specified date range. | <ul><li>Adds a method to retrieve top spending users from the database.</li><li>Calculates the total spent and average order value for each user.</li><li>Orders the users by total spending in descending order.</li><li>Limits the result to the top 10 users.</li><li>Includes error logging for debugging purposes.</li></ul> | `src/backend/WebService/src/Infrastructure/Repositories/UserRepository.cs`<br/>`src/backend/WebService/src/Domain/Repositories/IUserRepository.cs`<br/>`src/backend/WebService/src/Application/Features/ReportsService/Queries/GetTopSpendingUserQueryHandler.cs`<br/>`src/backend/WebService/src/Domain/DTOs/GetTopSpendingUsersDto.cs`<br/>`src/backend/WebService/src/Domain/DTOs/ListTopSpendingUserDto.cs`<br/>`src/backend/WebService/src/Application/Features/ReportsService/Queries/Response/GetTopSpendingUserResponse.cs`<br/>`src/backend/WebService/src/WebApi/Controllers/Reports/ReportController.cs`<br/>`src/backend/WebService/src/Application/Constant/IConstantMessage.cs` |
| Implements the calculation of user retention rates based on order activity. | <ul><li>Adds a method to calculate user retention rates.</li><li>Calculates retention rates for 1, 3, 6, and 12-month periods.</li><li>Calculates retention based on users who placed orders within the specified periods after registration.</li><li>Includes error logging for debugging purposes.</li></ul> | `src/backend/WebService/src/Infrastructure/Repositories/UserRepository.cs`<br/>`src/backend/WebService/src/Application/Features/ReportsService/Queries/GetUserRetentionQueryHandler.cs`<br/>`src/backend/WebService/src/Domain/DTOs/GetUserRetentionRateDto.cs`<br/>`src/backend/WebService/src/WebApi/Controllers/Reports/ReportController.cs`<br/>`src/backend/WebService/src/Application/Constant/IConstantMessage.cs` |

---

<details>
<summary>Tips and commands</summary>

#### Interacting with Sourcery

- **Trigger a new review:** Comment `@sourcery-ai review` on the pull request.
- **Continue discussions:** Reply directly to Sourcery's review comments.
- **Generate a GitHub issue from a review comment:** Ask Sourcery to create an
  issue from a review comment by replying to it. You can also reply to a
  review comment with `@sourcery-ai issue` to create an issue from it.
- **Generate a pull request title:** Write `@sourcery-ai` anywhere in the pull
  request title to generate a title at any time. You can also comment
  `@sourcery-ai title` on the pull request to (re-)generate the title at any time.
- **Generate a pull request summary:** Write `@sourcery-ai summary` anywhere in
  the pull request body to generate a PR summary at any time exactly where you
  want it. You can also comment `@sourcery-ai summary` on the pull request to
  (re-)generate the summary at any time.
- **Generate reviewer's guide:** Comment `@sourcery-ai guide` on the pull
  request to (re-)generate the reviewer's guide at any time.
- **Resolve all Sourcery comments:** Comment `@sourcery-ai resolve` on the
  pull request to resolve all Sourcery comments. Useful if you've already
  addressed all the comments and don't want to see them anymore.
- **Dismiss all Sourcery reviews:** Comment `@sourcery-ai dismiss` on the pull
  request to dismiss all existing Sourcery reviews. Especially useful if you
  want to start fresh with a new review - don't forget to comment
  `@sourcery-ai review` to trigger a new review!
- **Generate a plan of action for an issue:** Comment `@sourcery-ai plan` on
  an issue to generate a plan of action for it.

#### Customizing Your Experience

Access your [dashboard](https://app.sourcery.ai) to:
- Enable or disable review features such as the Sourcery-generated pull request
  summary, the reviewer's guide, and others.
- Change the review language.
- Add, remove or edit custom review instructions.
- Adjust other review settings.

#### Getting Help

- [Contact our support team](mailto:support@sourcery.ai) for questions or feedback.
- Visit our [documentation](https://docs.sourcery.ai) for detailed guides and information.
- Keep in touch with the Sourcery team by following us on [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) or [GitHub](https://github.com/sourcery-ai).

</details>

<!-- Generated by sourcery-ai[bot]: end review_guide -->
author_association: NONE
user: login: vercel[bot]
created_at: 2025-03-28T16:09:20Z
body: [vc]: #IQDOCgR42jmAbCka2NJ4W9uSOiWD/V/THYLDDPMPJm8=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJza2luY2FyZS1wcm9kdWN0cy1zYWxlcy1zeXN0ZW0iLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vYW5keW5pbmV0eTlzLXByb2plY3RzL3NraW5jYXJlLXByb2R1Y3RzLXNhbGVzLXN5c3RlbS9KRTVGNDhtVlBuY1NSTXdMUnRZSlNZUTE5blpQIiwicHJldmlld1VybCI6InNraW5jYXJlLXByb2R1Y3RzLXNhbGVzLXN5c3RlbS1naXQtZjMyZjYxLWFuZHluaW5ldHk5cy1wcm9qZWN0cy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IlBFTkRJTkciLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoic2tpbmNhcmUtcHJvZHVjdHMtc2FsZXMtc3lzdGVtLWdpdC1mMzJmNjEtYW5keW5pbmV0eTlzLXByb2plY3RzLnZlcmNlbC5hcHAifX1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **skincare-products-sales-system** | 🔄 Building ([Inspect](https://vercel.com/andyninety9s-projects/skincare-products-sales-system/JE5F48mVPncSRMwLRtYJSYQ19nZP)) | [Visit Preview](https://skincare-products-sales-system-git-f32f61-andyninety9s-projects.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/skincare-products-sales-system-git-f32f61-andyninety9s-projects.vercel.app?via=pr-comment-feedback-link) | Mar 28, 2025 4:09pm |


author_association: NONE