From 0acc9a1f8e994dbc265f960db153d9a3e4cf8d59 Mon Sep 17 00:00:00 2001 From: moarten Date: Tue, 14 Apr 2026 21:47:46 +0200 Subject: [PATCH] Added endpoints for specific verspakketten, added sorting for All Verspakketten endpoint --- Lutra/.github/copilot-instructions.md | 2 +- .../Controllers/VerspakkettenController.cs | 34 +++- .../Verspakketten/GetVerspakket.Handler.cs | 42 +++++ .../Verspakketten/GetVerspakket.Query.cs | 9 + .../Verspakketten/GetVerspakket.Response.cs | 12 ++ .../Verspakketten/GetVerspakket.cs | 4 + .../Verspakketten/GetVerspakketten.Handler.cs | 29 +++- .../Verspakketten/GetVerspakketten.Query.cs | 6 +- .../Verspakketten/SortDirection.cs | 7 + .../Verspakketten/VerspakketSortField.cs | 9 + Lutra/Lutra.Domain/Entities/Verspakket.cs | 2 + .../LutraDbContextFactory.cs | 20 +++ ...7_AddPrijsInCentenToVerspakket.Designer.cs | 154 ++++++++++++++++++ ...0414193437_AddPrijsInCentenToVerspakket.cs | 28 ++++ .../Migrations/LutraDbContextModelSnapshot.cs | 3 + 15 files changed, 351 insertions(+), 10 deletions(-) create mode 100644 Lutra/Lutra.Application/Verspakketten/GetVerspakket.Handler.cs create mode 100644 Lutra/Lutra.Application/Verspakketten/GetVerspakket.Query.cs create mode 100644 Lutra/Lutra.Application/Verspakketten/GetVerspakket.Response.cs create mode 100644 Lutra/Lutra.Application/Verspakketten/GetVerspakket.cs create mode 100644 Lutra/Lutra.Application/Verspakketten/SortDirection.cs create mode 100644 Lutra/Lutra.Application/Verspakketten/VerspakketSortField.cs create mode 100644 Lutra/Lutra.Infrastructure/LutraDbContextFactory.cs create mode 100644 Lutra/Lutra.Infrastructure/Migrations/20260414193437_AddPrijsInCentenToVerspakket.Designer.cs create mode 100644 Lutra/Lutra.Infrastructure/Migrations/20260414193437_AddPrijsInCentenToVerspakket.cs diff --git a/Lutra/.github/copilot-instructions.md b/Lutra/.github/copilot-instructions.md index bb196f4..762b1f1 100644 --- a/Lutra/.github/copilot-instructions.md +++ b/Lutra/.github/copilot-instructions.md @@ -80,7 +80,7 @@ Add FluentValidation, AutoMapper, NUnit, Shouldly, Moq, or Respawn only if the r ## Naming and code style -- Match the existing language of the codebase; this project uses Dutch domain names in places such as `Verspakketten`, `Supermarkt`, and `Beoordeling`. +- Use English for general code names (variables, method names, namespaces, folders) and use Dutch only for domain-specific enums, types, and files that reflect business concepts (e.g., `Verspakketten`, `Supermarkt`, `Beoordeling`). - Keep class and file names consistent with the feature name. - Use sealed types where the project already does. - Prefer explicit `required` members where the current codebase uses them. diff --git a/Lutra/Lutra.API/Controllers/VerspakkettenController.cs b/Lutra/Lutra.API/Controllers/VerspakkettenController.cs index 27c518e..e0d39a5 100644 --- a/Lutra/Lutra.API/Controllers/VerspakkettenController.cs +++ b/Lutra/Lutra.API/Controllers/VerspakkettenController.cs @@ -15,14 +15,40 @@ namespace Lutra.API.Controllers /// /// Gets a page of verspakketten. /// - /// The number of items to skip. - /// The maximum number of items to return. + /// The number of items to skip. Default: 0. + /// The maximum number of items to return. Default: 50. + /// The field to sort by: Naam, PrijsInCenten, AverageCijferSmaak, or AverageCijferBereiden. Default: Naam. + /// The sort direction: Ascending or Descending. Default: Ascending. /// The requested verspakket page. [HttpGet] [ProducesResponseType(typeof(GetVerspakketten.Response), StatusCodes.Status200OK)] - public async Task Get(int skip = 0, int take = 50) + public async Task Get( + int skip = 0, + int take = 50, + VerspakketSortField sortField = VerspakketSortField.Naam, + SortDirection sortDirection = SortDirection.Ascending) { - return await mediator.SendQueryAsync(new GetVerspakketten.Query(skip, take)); + return await mediator.SendQueryAsync( + new GetVerspakketten.Query(skip, take, sortField, sortDirection)); + } + + /// + /// Gets a specific verspakket by ID. + /// + /// The verspakket ID. + /// Returns 200 OK with the verspakket when found, or 404 Not Found when not found. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(GetVerspakket.Response), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(Guid id) + { + var result = await mediator.SendQueryAsync(new GetVerspakket.Query(id)); + if (result?.Verspakket == null) + { + return NotFound(); + } + + return Ok(result); } } } \ No newline at end of file diff --git a/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Handler.cs b/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Handler.cs new file mode 100644 index 0000000..fb0f059 --- /dev/null +++ b/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Handler.cs @@ -0,0 +1,42 @@ +using Cortex.Mediator.Queries; +using Lutra.Application.Interfaces; +using Lutra.Application.Models.Supermarkten; +using Lutra.Application.Models.Verspakketten; +using Microsoft.EntityFrameworkCore; + +namespace Lutra.Application.Verspakketten +{ + public sealed partial class GetVerspakket + { + public sealed class Handler(ILutraDbContext context) : IQueryHandler + { + public async Task Handle(Query request, CancellationToken cancellationToken) + { + var verspakket = await context.Verspaketten + .AsNoTracking() + .Where(v => v.Id == request.Id) + .Select(v => new Verspakket + { + Naam = v.Naam, + PrijsInCenten = v.PrijsInCenten, + Beoordelingen = v.Beoordelingen + .Select(b => new Beoordeling + { + CijferSmaak = b.CijferSmaak, + CijferBereiden = b.CijferBereiden, + Aanbevolen = b.Aanbevolen, + Tekst = b.Tekst + }) + .ToArray(), + Supermarkt = new Supermarkt + { + Naam = v.Supermarkt.Naam + } + }) + .SingleOrDefaultAsync(cancellationToken); + + return verspakket is null ? null : new Response { Verspakket = verspakket }; + } + } + } +} diff --git a/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Query.cs b/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Query.cs new file mode 100644 index 0000000..af44c30 --- /dev/null +++ b/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Query.cs @@ -0,0 +1,9 @@ +using Cortex.Mediator.Queries; + +namespace Lutra.Application.Verspakketten +{ + public sealed partial class GetVerspakket + { + public record Query(Guid Id) : IQuery; + } +} diff --git a/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Response.cs b/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Response.cs new file mode 100644 index 0000000..83f78d0 --- /dev/null +++ b/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Response.cs @@ -0,0 +1,12 @@ +using Lutra.Application.Models.Verspakketten; + +namespace Lutra.Application.Verspakketten +{ + public sealed partial class GetVerspakket + { + public sealed class Response + { + public required Verspakket Verspakket { get; set; } + } + } +} diff --git a/Lutra/Lutra.Application/Verspakketten/GetVerspakket.cs b/Lutra/Lutra.Application/Verspakketten/GetVerspakket.cs new file mode 100644 index 0000000..19039f6 --- /dev/null +++ b/Lutra/Lutra.Application/Verspakketten/GetVerspakket.cs @@ -0,0 +1,4 @@ +namespace Lutra.Application.Verspakketten +{ + public sealed partial class GetVerspakket { } +} diff --git a/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Handler.cs b/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Handler.cs index 2ae776a..d04ded3 100644 --- a/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Handler.cs +++ b/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Handler.cs @@ -12,15 +12,36 @@ namespace Lutra.Application.Verspakketten { public async Task Handle(Query request, CancellationToken cancellationToken) { - var verspakketten = await context.Verspaketten - .AsNoTracking() - .OrderBy(v => v.Naam) + var query = context.Verspaketten.AsNoTracking(); + + // Apply sort before pagination so the database handles ordering efficiently. + IOrderedQueryable sorted = request.SortField switch + { + VerspakketSortField.AverageCijferSmaak => + request.SortDirection == SortDirection.Ascending + ? query.OrderBy(v => v.Beoordelingen.Average(b => (double)b.CijferSmaak)) + : query.OrderByDescending(v => v.Beoordelingen.Average(b => (double)b.CijferSmaak)), + VerspakketSortField.AverageCijferBereiden => + request.SortDirection == SortDirection.Ascending + ? query.OrderBy(v => v.Beoordelingen.Average(b => (double)b.CijferBereiden)) + : query.OrderByDescending(v => v.Beoordelingen.Average(b => (double)b.CijferBereiden)), + VerspakketSortField.PrijsInCenten => + request.SortDirection == SortDirection.Ascending + ? query.OrderBy(v => v.PrijsInCenten) + : query.OrderByDescending(v => v.PrijsInCenten), + VerspakketSortField.Naam or _ => + request.SortDirection == SortDirection.Ascending + ? query.OrderBy(v => v.Naam) + : query.OrderByDescending(v => v.Naam), + }; + + var verspakketten = await sorted .Skip(request.Skip) .Take(request.Take) .Select(v => new Verspakket { Naam = v.Naam, - PrijsInCenten = null, + PrijsInCenten = v.PrijsInCenten, Beoordelingen = v.Beoordelingen .Select(b => new Beoordeling { diff --git a/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Query.cs b/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Query.cs index 72396a9..2188a6b 100644 --- a/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Query.cs +++ b/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Query.cs @@ -4,6 +4,10 @@ namespace Lutra.Application.Verspakketten { public sealed partial class GetVerspakketten { - public record Query(int Skip, int Take) : IQuery; + public record Query( + int Skip, + int Take, + VerspakketSortField SortField = VerspakketSortField.Naam, + SortDirection SortDirection = SortDirection.Ascending) : IQuery; } } diff --git a/Lutra/Lutra.Application/Verspakketten/SortDirection.cs b/Lutra/Lutra.Application/Verspakketten/SortDirection.cs new file mode 100644 index 0000000..15417ce --- /dev/null +++ b/Lutra/Lutra.Application/Verspakketten/SortDirection.cs @@ -0,0 +1,7 @@ +namespace Lutra.Application.Verspakketten; + +public enum SortDirection +{ + Ascending, + Descending +} diff --git a/Lutra/Lutra.Application/Verspakketten/VerspakketSortField.cs b/Lutra/Lutra.Application/Verspakketten/VerspakketSortField.cs new file mode 100644 index 0000000..286d8ee --- /dev/null +++ b/Lutra/Lutra.Application/Verspakketten/VerspakketSortField.cs @@ -0,0 +1,9 @@ +namespace Lutra.Application.Verspakketten; + +public enum VerspakketSortField +{ + Naam, + PrijsInCenten, + AverageCijferSmaak, + AverageCijferBereiden +} diff --git a/Lutra/Lutra.Domain/Entities/Verspakket.cs b/Lutra/Lutra.Domain/Entities/Verspakket.cs index a9ac390..2d6bb95 100644 --- a/Lutra/Lutra.Domain/Entities/Verspakket.cs +++ b/Lutra/Lutra.Domain/Entities/Verspakket.cs @@ -9,6 +9,8 @@ public class Verspakket : BaseEntity [MaxLength(50)] public required string Naam { get; set; } + public int? PrijsInCenten { get; set; } + [Range(1, 10)] public int AantalPersonen { get; set; } diff --git a/Lutra/Lutra.Infrastructure/LutraDbContextFactory.cs b/Lutra/Lutra.Infrastructure/LutraDbContextFactory.cs new file mode 100644 index 0000000..4d1aab2 --- /dev/null +++ b/Lutra/Lutra.Infrastructure/LutraDbContextFactory.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Lutra.Infrastructure.Sql; + +/// +/// Design-time factory used by EF Core tools (dotnet ef migrations) so that +/// no startup project or live database connection is required when running migrations. +/// +internal sealed class LutraDbContextFactory : IDesignTimeDbContextFactory +{ + public LutraDbContext CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Database=lutra;Username=postgres;Password=postgres") + .Options; + + return new LutraDbContext(options); + } +} diff --git a/Lutra/Lutra.Infrastructure/Migrations/20260414193437_AddPrijsInCentenToVerspakket.Designer.cs b/Lutra/Lutra.Infrastructure/Migrations/20260414193437_AddPrijsInCentenToVerspakket.Designer.cs new file mode 100644 index 0000000..4622b76 --- /dev/null +++ b/Lutra/Lutra.Infrastructure/Migrations/20260414193437_AddPrijsInCentenToVerspakket.Designer.cs @@ -0,0 +1,154 @@ +// +using System; +using Lutra.Infrastructure.Sql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Lutra.Infrastructure.Sql.Migrations +{ + [DbContext(typeof(LutraDbContext))] + [Migration("20260414193437_AddPrijsInCentenToVerspakket")] + partial class AddPrijsInCentenToVerspakket + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Aanbevolen") + .HasColumnType("boolean"); + + b.Property("CijferBereiden") + .HasColumnType("integer"); + + b.Property("CijferSmaak") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Tekst") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("VerspakketId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("VerspakketId"); + + b.ToTable("Beoordelingen", (string)null); + }); + + modelBuilder.Entity("Lutra.Domain.Entities.Supermarkt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Naam") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Supermarkten"); + }); + + modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AantalPersonen") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Naam") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PrijsInCenten") + .HasColumnType("integer"); + + b.Property("SupermarktId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SupermarktId"); + + b.ToTable("Verspaketten"); + }); + + modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b => + { + b.HasOne("Lutra.Domain.Entities.Verspakket", null) + .WithMany("Beoordelingen") + .HasForeignKey("VerspakketId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b => + { + b.HasOne("Lutra.Domain.Entities.Supermarkt", "Supermarkt") + .WithMany() + .HasForeignKey("SupermarktId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Supermarkt"); + }); + + modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b => + { + b.Navigation("Beoordelingen"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Lutra/Lutra.Infrastructure/Migrations/20260414193437_AddPrijsInCentenToVerspakket.cs b/Lutra/Lutra.Infrastructure/Migrations/20260414193437_AddPrijsInCentenToVerspakket.cs new file mode 100644 index 0000000..6dd49ae --- /dev/null +++ b/Lutra/Lutra.Infrastructure/Migrations/20260414193437_AddPrijsInCentenToVerspakket.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Lutra.Infrastructure.Sql.Migrations +{ + /// + public partial class AddPrijsInCentenToVerspakket : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PrijsInCenten", + table: "Verspaketten", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PrijsInCenten", + table: "Verspaketten"); + } + } +} diff --git a/Lutra/Lutra.Infrastructure/Migrations/LutraDbContextModelSnapshot.cs b/Lutra/Lutra.Infrastructure/Migrations/LutraDbContextModelSnapshot.cs index 9677f11..8dd149f 100644 --- a/Lutra/Lutra.Infrastructure/Migrations/LutraDbContextModelSnapshot.cs +++ b/Lutra/Lutra.Infrastructure/Migrations/LutraDbContextModelSnapshot.cs @@ -108,6 +108,9 @@ namespace Lutra.Infrastructure.Sql.Migrations .HasMaxLength(50) .HasColumnType("character varying(50)"); + b.Property("PrijsInCenten") + .HasColumnType("integer"); + b.Property("SupermarktId") .HasColumnType("uuid");