From 73ae6eadd089668dd44c11ac3cc5c52d3a243ba2 Mon Sep 17 00:00:00 2001 From: Ahmed Gamal Date: Wed, 16 Sep 2026 22:15:51 -0700 Subject: [PATCH] SCRUM-325-REWARD-EP-6 --- src/Buy2.Api/Controllers/RewardsController.cs | 27 +++ .../DTOs/Rewards/DTOs/RewardAnalyticsDtos.cs | 39 +++++ .../DTOs/Rewards/DTOs/RewardProfileDtos.cs | 15 -- .../Queries/GetRewardAnalyticsQuery.cs | 159 ++++++++++++++++++ 4 files changed, 225 insertions(+), 15 deletions(-) create mode 100644 src/Buy2.Application/DTOs/Rewards/DTOs/RewardAnalyticsDtos.cs create mode 100644 src/Buy2.Application/Features/Rewards/Queries/GetRewardAnalyticsQuery.cs diff --git a/src/Buy2.Api/Controllers/RewardsController.cs b/src/Buy2.Api/Controllers/RewardsController.cs index a9bf796..6c50256 100644 --- a/src/Buy2.Api/Controllers/RewardsController.cs +++ b/src/Buy2.Api/Controllers/RewardsController.cs @@ -139,4 +139,31 @@ public async Task GetRewardById(int id, CancellationToken cancell return Ok(result.Value); } + + [HttpGet("{id}/analytics")] + [Authorize(Roles = "HRAdmin,Admin,SuperAdmin")] + [ProducesResponseType(typeof(RewardAnalyticsDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetRewardAnalytics( + int id, + [FromQuery] RewardTransactionFilterQueryDto filter, + CancellationToken cancellation) + { + var result = await _mediator.Send(new GetRewardAnalyticsQuery(id, filter), cancellation); + + if (result.IsNotFound) + { + return NotFound(new { message = result.ErrorMessage }); + } + + if (!result.IsSuccess) + { + return BadRequest(new { message = result.ErrorMessage }); + } + + return Ok(result.Value); + } } \ No newline at end of file diff --git a/src/Buy2.Application/DTOs/Rewards/DTOs/RewardAnalyticsDtos.cs b/src/Buy2.Application/DTOs/Rewards/DTOs/RewardAnalyticsDtos.cs new file mode 100644 index 0000000..71a11a0 --- /dev/null +++ b/src/Buy2.Application/DTOs/Rewards/DTOs/RewardAnalyticsDtos.cs @@ -0,0 +1,39 @@ +namespace Buy2.Application.DTOs.Rewards.DTOs; + +public record RewardTransactionFilterQueryDto( + DateTimeOffset? DateFrom, + DateTimeOffset? DateTo, + string? TimelinePeriod = "Monthly", + string? SearchTerm = null, + int PageNumber = 1, + int PageSize = 10 +); + +public record RedemptionTimelinePointDto( + string PeriodLabel, + DateTimeOffset DateFrom, + DateTimeOffset DateTo, + int RedemptionCount, + int TotalPointsSpent +); + +public record RewardTransactionItemDto( + int Id, + int EmployeeId, + string EmployeeName, + string EmployeeCode, + string DepartmentName, + string VoucherCode, + DateTimeOffset RedeemedAt, + TimeSpan Time, + int PointsDeducted +); + +public record RewardAnalyticsDto( + IReadOnlyCollection Timeline, + IReadOnlyCollection Transactions, + int TotalCount, + int PageNumber, + int PageSize, + int TotalPages +); \ No newline at end of file diff --git a/src/Buy2.Application/DTOs/Rewards/DTOs/RewardProfileDtos.cs b/src/Buy2.Application/DTOs/Rewards/DTOs/RewardProfileDtos.cs index 4898398..3d53625 100644 --- a/src/Buy2.Application/DTOs/Rewards/DTOs/RewardProfileDtos.cs +++ b/src/Buy2.Application/DTOs/Rewards/DTOs/RewardProfileDtos.cs @@ -22,21 +22,6 @@ public record RewardKpiStatistics( int TopRedeemed, int PointsValue ); -// Reward Analytics Chart: -public record RewardRedemptionAnalyticsChartDto( - DateTimeOffset StartTime, - DateTimeOffset EndTime, - int TotalRedemptions -); -// Reward Transaction History Log: -public record RewardTransactionHistoryLogDto( - int TransactionId, - string EmployeeName, - DateTimeOffset Date, - DateTime Time, - string Code -); - public record RewardProfileResponseDto( RewardProfileListDto Profile, RewardKpiStatistics KpiStats diff --git a/src/Buy2.Application/Features/Rewards/Queries/GetRewardAnalyticsQuery.cs b/src/Buy2.Application/Features/Rewards/Queries/GetRewardAnalyticsQuery.cs new file mode 100644 index 0000000..01929f7 --- /dev/null +++ b/src/Buy2.Application/Features/Rewards/Queries/GetRewardAnalyticsQuery.cs @@ -0,0 +1,159 @@ +using Buy2.Application.Common.Interfaces; +using Buy2.Application.Common.Models; +using Buy2.Application.DTOs.Rewards.DTOs; +using Buy2.Domain.Entities; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Buy2.Application.Features.Rewards.Queries; + +public record GetRewardAnalyticsQuery( + int Id, + RewardTransactionFilterQueryDto Filter +) : IRequest>; + +public class GetRewardAnalyticsQueryHandler : IRequestHandler> +{ + private readonly IRepository _rewardItemRepository; + private readonly IRepository _redemptionRepository; + + public GetRewardAnalyticsQueryHandler( + IRepository rewardItemRepository, + IRepository redemptionRepository) + { + _rewardItemRepository = rewardItemRepository; + _redemptionRepository = redemptionRepository; + } + + public async Task> Handle(GetRewardAnalyticsQuery query, CancellationToken cancellation) + { + var rewardItem = await _rewardItemRepository + .Query(true) + .FirstOrDefaultAsync(r => r.Id == query.Id, cancellation); + + if (rewardItem is null) + { + return Result.NotFound("Reward item not found."); + } + + var (dateFrom, dateTo, isWeekly) = ResolveDateBounds(query.Filter); + + var timeline = await BuildTimelineAsync(query.Id, dateFrom, dateTo, isWeekly, cancellation); + + var transactions = await BuildTransactionsAsync(query, dateFrom, dateTo, cancellation); + + var totalPages = transactions.TotalCount == 0 + ? 0 + : (int)Math.Ceiling((double)transactions.TotalCount / transactions.PageSize); + + return Result.Success(new RewardAnalyticsDto( + timeline, + transactions.Items.ToList(), + transactions.TotalCount, + transactions.Page, + transactions.PageSize, + totalPages + )); + } + + private static (DateTimeOffset From, DateTimeOffset To, bool IsWeekly) ResolveDateBounds(RewardTransactionFilterQueryDto filter) + { + var to = filter.DateTo ?? DateTimeOffset.UtcNow; + var from = filter.DateFrom ?? to.AddMonths(-6); + var isWeekly = string.Equals(filter.TimelinePeriod, "Weekly", StringComparison.OrdinalIgnoreCase); + return (from, to, isWeekly); + } + + private async Task> BuildTimelineAsync( + int rewardId, DateTimeOffset dateFrom, DateTimeOffset dateTo, bool isWeekly, CancellationToken cancellation) + { + var redemptions = await _redemptionRepository + .Query(true) + .Where(r => + r.RewardItemId == rewardId && + r.RedeemedAt >= dateFrom && + r.RedeemedAt <= dateTo) + .Select(r => new + { + r.RedeemedAt, + PointsSpent = r.PointsTransaction != null ? Math.Abs(r.PointsTransaction.Amount) : 0 + }) + .ToListAsync(cancellation); + + return isWeekly + ? redemptions + .GroupBy(r => StartOfWeek(r.RedeemedAt)) + .Select(g => new RedemptionTimelinePointDto( + g.Key.ToString("dd MMM yyyy"), + g.Key, + g.Key.AddDays(6), + g.Count(), + g.Sum(r => r.PointsSpent))) + .OrderBy(p => p.DateFrom) + .ToList() + : redemptions + .GroupBy(r => new DateTimeOffset(r.RedeemedAt.DateTime.Year, r.RedeemedAt.DateTime.Month, 1, 0, 0, 0, TimeSpan.Zero)) + .Select(g => new RedemptionTimelinePointDto( + g.Key.ToString("MMM yyyy"), + g.Key, + g.Key.AddMonths(1).AddDays(-1), + g.Count(), + g.Sum(r => r.PointsSpent))) + .OrderBy(p => p.DateFrom) + .ToList(); + } + + private async Task> BuildTransactionsAsync( + GetRewardAnalyticsQuery query, DateTimeOffset dateFrom, DateTimeOffset dateTo, CancellationToken cancellation) + { + var redemptionQuery = _redemptionRepository + .Query(true) + .Where(r => + r.RewardItemId == query.Id && + r.RedeemedAt >= dateFrom && + r.RedeemedAt <= dateTo); + + if (!string.IsNullOrWhiteSpace(query.Filter.SearchTerm)) + { + var search = query.Filter.SearchTerm.Trim(); + redemptionQuery = redemptionQuery.Where(r => + r.Employee.FirstName.Contains(search) || + r.Employee.LastName.Contains(search) || + r.Employee.EmployeeCode.Contains(search) || + r.VoucherCode.Contains(search)); + } + + var totalCount = await redemptionQuery.CountAsync(cancellation); + + var pageNumber = Math.Max(1, query.Filter.PageNumber); + var pageSize = query.Filter.PageSize < 1 ? 10 : Math.Min(query.Filter.PageSize, 100); + + var items = await redemptionQuery + .OrderByDescending(r => r.RedeemedAt) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .Select(r => new RewardTransactionItemDto( + r.Id, + r.EmployeeId, + r.Employee.FirstName + " " + r.Employee.LastName, + r.Employee.EmployeeCode, + r.Employee.JobRole != null && r.Employee.JobRole.Department != null + ? r.Employee.JobRole.Department.Name + : string.Empty, + r.VoucherCode, + r.RedeemedAt, + r.RedeemedAt.DateTime.TimeOfDay, + Math.Abs(r.PointsTransaction.Amount) + )) + .ToListAsync(cancellation); + + return new PageResultDto(items, totalCount, pageNumber, pageSize); + } + + private static DateTimeOffset StartOfWeek(DateTimeOffset date) + { + var day = date.DateTime.Date; + var diff = (7 + (day.DayOfWeek - DayOfWeek.Monday)) % 7; + return new DateTimeOffset(day.AddDays(-diff), TimeSpan.Zero); + } +} \ No newline at end of file