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");