diff --git a/coffeecard/CoffeeCard.Library/Services/v2/IStatisticsService.cs b/coffeecard/CoffeeCard.Library/Services/v2/IStatisticsService.cs new file mode 100644 index 00000000..eb370f40 --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Services/v2/IStatisticsService.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using CoffeeCard.Models.DataTransferObjects.v2.Statistics; +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Library.Services.v2 +{ + public interface IStatisticsService + { + Task> GetQuickStatsAsync(User user); + } +} diff --git a/coffeecard/CoffeeCard.Library/Services/v2/StatisticsService.cs b/coffeecard/CoffeeCard.Library/Services/v2/StatisticsService.cs new file mode 100644 index 00000000..b6d46c4f --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Services/v2/StatisticsService.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CoffeeCard.Library.Persistence; +using CoffeeCard.Library.Utils; +using CoffeeCard.Models.DataTransferObjects.v2.Statistics; +using CoffeeCard.Models.Entities; +using Microsoft.EntityFrameworkCore; + +namespace CoffeeCard.Library.Services.v2 +{ + public class StatisticsService : IStatisticsService + { + private readonly CoffeeCardContext _context; + private readonly IDateTimeProvider _dateTimeProvider; + + public StatisticsService(CoffeeCardContext context, IDateTimeProvider dateTimeProvider) + { + _context = context; + _dateTimeProvider = dateTimeProvider; + } + + public async Task> GetQuickStatsAsync(User user) + { + var utcNow = _dateTimeProvider.UtcNow(); + var todayStart = utcNow.Date; + var todayEnd = todayStart.AddDays(1); + var weekStart = GetWeekStart(utcNow); + var weekEnd = weekStart.AddDays(7); + + var totalDrinks = await _context.Tickets.CountAsync(ticket => + ticket.OwnerId == user.Id && ticket.Status == TicketStatus.Used + ); + + var drinksToday = await _context.Tickets.CountAsync(ticket => + ticket.Status == TicketStatus.Used + && ticket.DateUsed >= todayStart + && ticket.DateUsed < todayEnd + ); + + var favouriteDrink = await _context + .Tickets.Where(ticket => + ticket.OwnerId == user.Id + && ticket.Status == TicketStatus.Used + && ticket.UsedOnMenuItemId != null + ) + .GroupBy(ticket => ticket.UsedOnMenuItem.Name) + .Select(group => new { MenuItemName = group.Key, Count = group.Count() }) + .OrderByDescending(group => group.Count) + .ThenBy(group => group.MenuItemName) + .FirstOrDefaultAsync(); + + var drinksThisWeek = await _context.Tickets.CountAsync(ticket => + ticket.OwnerId == user.Id + && ticket.Status == TicketStatus.Used + && ticket.DateUsed >= weekStart + && ticket.DateUsed < weekEnd + ); + + return new List + { + new QuickStatResponse + { + Key = "total-drinks-user", + Title = "Drinks consumed by you", + Value = totalDrinks, + SupportingText = null, + }, + new QuickStatResponse + { + Key = "global-drinks-today", + Title = "Drinks consumed by ITU today", + Value = drinksToday, + SupportingText = null, + }, + new QuickStatResponse + { + Key = "favourite-drink", + Title = "Your favourite drink", + Value = favouriteDrink?.Count ?? 0, + SupportingText = favouriteDrink?.MenuItemName ?? "No drinks yet", + }, + new QuickStatResponse + { + Key = "drinks-this-week-user", + Title = "Drinks this week", + Value = drinksThisWeek, + SupportingText = null, + }, + }; + } + + private static DateTime GetWeekStart(DateTime utcNow) + { + var daysSinceMonday = ((int)utcNow.DayOfWeek + 6) % 7; + return utcNow.Date.AddDays(-daysSinceMonday); + } + } +} diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Statistics/QuickStatResponse.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Statistics/QuickStatResponse.cs new file mode 100644 index 00000000..78aa6275 --- /dev/null +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Statistics/QuickStatResponse.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; + +namespace CoffeeCard.Models.DataTransferObjects.v2.Statistics +{ + /// + /// A small stat card used by the quick statistics endpoint. + /// + /// + /// { + /// "key": "favourite-drink", + /// "title": "Your favourite drink", + /// "value": "128", + /// "supportingText": "Filter Coffee" + /// } + /// + public class QuickStatResponse + { + /// + /// Stable stat identifier. + /// + [Required] + public required string Key { get; set; } + + /// + /// Card title. + /// + [Required] + public required string Title { get; set; } + + /// + /// Main value shown in the card. + /// + [Required] + public required int Value { get; set; } + + /// + /// Smaller supporting text shown below the main value. + /// + [Required] + public required string? SupportingText { get; set; } + } +} diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/v2/StatisticsServiceTests.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/StatisticsServiceTests.cs new file mode 100644 index 00000000..42623df6 --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/v2/StatisticsServiceTests.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CoffeeCard.Common.Configuration; +using CoffeeCard.Library.Persistence; +using CoffeeCard.Library.Services.v2; +using CoffeeCard.Library.Utils; +using CoffeeCard.Models.DataTransferObjects.v2.Purchase; +using CoffeeCard.Models.Entities; +using CoffeeCard.Tests.Common.Builders; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace CoffeeCard.Tests.Unit.Services.v2 +{ + public class StatisticsServiceTests + { + [Fact(DisplayName = "GetQuickStatsAsync returns the four quick stats")] + public async Task GetQuickStatsAsyncReturnsFourQuickStats() + { + // Arrange + var builder = new DbContextOptionsBuilder().UseInMemoryDatabase( + nameof(GetQuickStatsAsyncReturnsFourQuickStats) + ); + + var databaseSettings = new DatabaseSettings { SchemaName = "test" }; + var environmentSettings = new EnvironmentSettings() + { + EnvironmentType = EnvironmentType.Test, + }; + + await using var context = new CoffeeCardContext( + builder.Options, + databaseSettings, + environmentSettings + ); + + var user = UserBuilder.DefaultCustomer().WithId(1).Build(); + + var otherUser = UserBuilder.DefaultCustomer().WithId(2).Build(); + + context.Users.AddRange(user, otherUser); + + var currentTime = new DateTime(2026, 5, 3, 12, 0, 0, DateTimeKind.Utc); + + var latte = MenuItemBuilder.Simple().WithId(1).WithName("Latte").Build(); + var americano = MenuItemBuilder.Simple().WithId(2).WithName("Americano").Build(); + var espresso = MenuItemBuilder.Simple().WithId(3).WithName("Espresso").Build(); + + context.MenuItems.AddRange(latte, americano, espresso); + + var largeProduct = ProductBuilder.Simple().WithId(1).WithName("Large").Build(); + var smallProduct = ProductBuilder.Simple().WithId(2).WithName("Small").Build(); + var fancyProduct = ProductBuilder.Simple().WithId(3).WithName("Fancy").Build(); + + context.Products.AddRange(largeProduct, smallProduct, fancyProduct); + + var purchase1 = PurchaseBuilder + .Simple() + .WithId(1) + .WithProductName("Large") + .WithProductId(largeProduct.Id) + .WithProduct(largeProduct) + .WithPrice(20) + .WithNumberOfTickets(1) + .WithOrderId("order-1") + .WithStatus(PurchaseStatus.Completed) + .WithType(PurchaseType.Free) + .WithPurchasedById(user.Id) + .WithPurchasedBy(user) + .WithDateCreated(currentTime.AddDays(-1)) + .Build(); + var purchase2 = PurchaseBuilder + .Simple() + .WithId(2) + .WithProductName("Small") + .WithProductId(smallProduct.Id) + .WithProduct(smallProduct) + .WithPrice(20) + .WithNumberOfTickets(1) + .WithOrderId("order-2") + .WithStatus(PurchaseStatus.Completed) + .WithType(PurchaseType.Free) + .WithPurchasedById(user.Id) + .WithPurchasedBy(user) + .WithDateCreated(currentTime.AddDays(-2)) + .Build(); + var purchase3 = PurchaseBuilder + .Simple() + .WithId(3) + .WithProductName("Large") + .WithProductId(largeProduct.Id) + .WithProduct(largeProduct) + .WithPrice(20) + .WithNumberOfTickets(1) + .WithOrderId("order-3") + .WithStatus(PurchaseStatus.Completed) + .WithType(PurchaseType.Free) + .WithPurchasedById(user.Id) + .WithPurchasedBy(user) + .WithDateCreated(currentTime.AddDays(-3)) + .Build(); + var purchase4 = PurchaseBuilder + .Simple() + .WithId(4) + .WithProductName("Small") + .WithProductId(fancyProduct.Id) + .WithProduct(fancyProduct) + .WithPrice(20) + .WithNumberOfTickets(1) + .WithOrderId("order-4") + .WithStatus(PurchaseStatus.Completed) + .WithType(PurchaseType.Free) + .WithPurchasedById(otherUser.Id) + .WithPurchasedBy(otherUser) + .WithDateCreated(currentTime.AddDays(-1)) + .Build(); + + var tickets = new List + { + TicketBuilder + .Simple() + .WithId(1) + .WithDateCreated(currentTime.AddDays(-3)) + .WithDateUsed(currentTime.AddDays(-3)) + .WithProductId(largeProduct.Id) + .WithStatus(TicketStatus.Used) + .WithOwnerId(user.Id) + .WithOwner(user) + .WithPurchaseId(purchase1.Id) + .WithPurchase(purchase1) + .WithUsedOnMenuItemId(latte.Id) + .WithUsedOnMenuItem(latte) + .Build(), + TicketBuilder + .Simple() + .WithId(2) + .WithDateCreated(currentTime.AddDays(-2)) + .WithDateUsed(currentTime.AddDays(-2)) + .WithProductId(smallProduct.Id) + .WithStatus(TicketStatus.Used) + .WithOwnerId(user.Id) + .WithOwner(user) + .WithPurchaseId(purchase2.Id) + .WithPurchase(purchase2) + .WithUsedOnMenuItemId(americano.Id) + .WithUsedOnMenuItem(americano) + .Build(), + TicketBuilder + .Simple() + .WithId(3) + .WithDateCreated(currentTime.AddDays(-1)) + .WithDateUsed(currentTime.AddDays(-1)) + .WithProductId(largeProduct.Id) + .WithStatus(TicketStatus.Used) + .WithOwnerId(user.Id) + .WithOwner(user) + .WithPurchaseId(purchase3.Id) + .WithPurchase(purchase3) + .WithUsedOnMenuItemId(latte.Id) + .WithUsedOnMenuItem(latte) + .Build(), + TicketBuilder + .Simple() + .WithId(4) + .WithDateCreated(currentTime) + .WithDateUsed(currentTime) + .WithProductId(fancyProduct.Id) + .WithStatus(TicketStatus.Used) + .WithOwnerId(otherUser.Id) + .WithOwner(otherUser) + .WithPurchaseId(purchase4.Id) + .WithPurchase(purchase4) + .WithUsedOnMenuItemId(espresso.Id) + .WithUsedOnMenuItem(espresso) + .Build(), + TicketBuilder + .Simple() + .WithId(5) + .WithDateCreated(currentTime) + .WithDateUsed(currentTime) + .WithProductId(fancyProduct.Id) + .WithStatus(TicketStatus.Used) + .WithOwnerId(otherUser.Id) + .WithOwner(otherUser) + .WithPurchaseId(purchase4.Id) + .WithPurchase(purchase4) + .WithUsedOnMenuItemId(espresso.Id) + .WithUsedOnMenuItem(espresso) + .Build(), + }; + + context.Purchases.AddRange(purchase1, purchase2, purchase3, purchase4); + context.Tickets.AddRange(tickets); + await context.SaveChangesAsync(); + + var dateTimeProvider = new Mock(); + dateTimeProvider.Setup(provider => provider.UtcNow()).Returns(currentTime); + + var statisticsService = new StatisticsService(context, dateTimeProvider.Object); + + // Act + var result = (await statisticsService.GetQuickStatsAsync(user)).ToList(); + + // Assert + Assert.Equal(4, result.Count); + + var totalDrinks = Assert.Single(result.Where(stat => stat.Key == "total-drinks-user")); + Assert.Equal(3, totalDrinks.Value); + Assert.Null(totalDrinks.SupportingText); + + var drinksToday = Assert.Single( + result.Where(stat => stat.Key == "global-drinks-today") + ); + Assert.Equal(2, drinksToday.Value); + Assert.Null(drinksToday.SupportingText); + + var favouriteDrink = Assert.Single(result.Where(stat => stat.Key == "favourite-drink")); + Assert.Equal(2, favouriteDrink.Value); + Assert.Equal("Latte", favouriteDrink.SupportingText); + + var drinksThisWeek = Assert.Single( + result.Where(stat => stat.Key == "drinks-this-week-user") + ); + Assert.Equal(3, drinksThisWeek.Value); + Assert.Null(drinksThisWeek.SupportingText); + } + } +} diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/StatisticsController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/StatisticsController.cs new file mode 100644 index 00000000..505f9ecc --- /dev/null +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/StatisticsController.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using CoffeeCard.Common.Errors; +using CoffeeCard.Library.Services.v2; +using CoffeeCard.Library.Utils; +using CoffeeCard.Models.DataTransferObjects.v2.Statistics; +using CoffeeCard.WebApi.Helpers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace CoffeeCard.WebApi.Controllers.v2 +{ + /// + /// Controller for public statistics endpoints. + /// + [ApiController] + [ApiVersion("2")] + [Route("api/v{version:apiVersion}/statistics")] + [Authorize] + public class StatisticsController : ControllerBase + { + private readonly IStatisticsService _statisticsService; + private readonly ClaimsUtilities _claimsUtilities; + + /// + /// Initializes a new instance of the class. + /// + public StatisticsController( + IStatisticsService statisticsService, + ClaimsUtilities claimsUtilities + ) + { + _statisticsService = statisticsService; + _claimsUtilities = claimsUtilities; + } + + /// + /// Get some quick stats about the user and general drink consumption at ITU. + /// + /// Quick stats + /// Invalid credentials + [HttpGet("quick")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status401Unauthorized)] + public async Task>> GetQuickStats() + { + var user = await _claimsUtilities.ValidateAndReturnUserFromClaimAsync(User.Claims); + return Ok(await _statisticsService.GetQuickStatsAsync(user)); + } + } +} diff --git a/coffeecard/CoffeeCard.WebApi/Startup.cs b/coffeecard/CoffeeCard.WebApi/Startup.cs index a2f5efd2..2190a51a 100644 --- a/coffeecard/CoffeeCard.WebApi/Startup.cs +++ b/coffeecard/CoffeeCard.WebApi/Startup.cs @@ -162,6 +162,7 @@ public void ConfigureServices(IServiceCollection services) >(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddFeatureManagement();