Compare commits

..

5 Commits

60 changed files with 1638 additions and 355 deletions
+14 -7
View File
@@ -1,4 +1,4 @@
# Copilot Instructions for Lutra
# Copilot Instructions for Lutra
Use these standards when generating or modifying code in this repository.
@@ -13,6 +13,8 @@ Use these standards when generating or modifying code in this repository.
- `Lutra.API`
- `Lutra.AppHost`
- `Lutra.Infrastructure.Migrator`
- `Lutra.Application.UnitTests` — xUnit unit tests for Application handlers
- integration tests for API behavior (`Lutra.API.IntegrationTests`)
- Database: PostgreSQL via EF Core
- Orchestration: .NET Aspire
- Messaging pattern: `Cortex.Mediator` for CQRS handling
@@ -31,10 +33,11 @@ Use these standards when generating or modifying code in this repository.
## Clean Architecture guidance
Use Jason Taylors CleanArchitecture project as a reference for intent and structure, but adapt to this codebases actual implementation.
Use Jason Taylor’s CleanArchitecture project as a reference for intent and structure, but adapt to this codebase’s actual implementation.
Where the reference template uses MediatR, this project uses `Cortex.Mediator`.
Add FluentValidation, AutoMapper, NUnit, Shouldly, Moq, or Respawn only if the repository already uses them or the change clearly requires them.
The established test stack is **xUnit** with **FluentAssertions** — both are already in use and should be used for all new tests.
Add FluentValidation, AutoMapper, Shouldly, Moq, or Respawn only if the repository already uses them or the change clearly requires them.
## Domain layer rules
@@ -57,6 +60,7 @@ Add FluentValidation, AutoMapper, NUnit, Shouldly, Moq, or Respawn only if the r
- Use `Cortex.Mediator` request and handler interfaces.
- Keep handlers focused on orchestration and application logic.
- Use `ILutraDbContext` rather than referencing the concrete DbContext directly when possible.
- Shared model types (DTOs, enums) that are not use-case-specific live in `Models/<FeatureArea>/` (e.g., `Models/Verspakketten/`, `Models/Supermarkten/`).
## Infrastructure rules
@@ -84,15 +88,18 @@ Add FluentValidation, AutoMapper, NUnit, Shouldly, Moq, or Respawn only if the r
- 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.
- Always use **file-scoped namespaces** (`namespace Foo.Bar;`), never block-scoped (`namespace Foo.Bar { }`).
- Preserve current indentation and brace style in each file.
## Testing guidance
- If tests exist, update or add tests alongside behavior changes.
- Follow the reference templates intent for test coverage:
- unit tests for business logic
- integration tests for infrastructure and data access
- functional tests for API behavior
- Follow the reference template's intent for test coverage:
- unit tests for business logic (`Lutra.Application.UnitTests`)
- integration tests for API behavior (`Lutra.API.IntegrationTests`)
- Test framework: **xUnit** (`xunit` v2 + `xunit.runner.visualstudio`).
- Assertions: **FluentAssertions**.
- Integration tests use **SQLite** (via `Microsoft.EntityFrameworkCore.Sqlite`) as the in-process test database -- not PostgreSQL. Use `LutraApiFactory` and `IntegrationTestBase` as the base infrastructure.
- Keep tests readable and focused on behavior.
## When making changes
@@ -32,12 +32,31 @@ public class VerspakkettenControllerTests(LutraApiFactory factory)
Id = Guid.NewGuid(), Naam = "AH",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
await SeedAsync(new Verspakket
var verspakket = new Verspakket
{
Id = Guid.NewGuid(), Naam = "Lente Pakket", AantalPersonen = 2,
SupermarktId = supermarkt.Id,
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
};
verspakket.AddFoto(new VerspakketFoto
{
Id = Guid.NewGuid(),
Data = [1, 2, 3],
IsMainImage = true,
VerspakketId = verspakket.Id,
CreatedAt = DateTime.UtcNow,
ModifiedAt = DateTime.UtcNow
});
verspakket.AddFoto(new VerspakketFoto
{
Id = Guid.NewGuid(),
Data = [4, 5, 6],
IsMainImage = false,
VerspakketId = verspakket.Id,
CreatedAt = DateTime.UtcNow,
ModifiedAt = DateTime.UtcNow
});
await SeedAsync(verspakket);
var response = await Client.GetAsync("/api/verspakketten");
@@ -45,6 +64,8 @@ public class VerspakkettenControllerTests(LutraApiFactory factory)
var body = await response.Content.ReadFromJsonAsync<GetVerspakketten.Response>();
body!.Verspakketten.Should().HaveCount(1);
body.Verspakketten.First().Naam.Should().Be("Lente Pakket");
body.Verspakketten.First().Foto.Should().NotBeNull();
body.Verspakketten.First().Foto!.IsMainImage.Should().BeTrue();
}
// ── GET /api/verspakketten/{id} ───────────────────────────────────────────
@@ -91,8 +112,8 @@ public class VerspakkettenControllerTests(LutraApiFactory factory)
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var command = new CreateVerspakket.Command("Herfst Pakket", 1499, 3, supermarkt.Id);
var response = await Client.PostAsJsonAsync("/api/verspakketten", command);
var request = new CreateVerspakketRequest("Herfst Pakket", 1499, 3, supermarkt.Id);
var response = await Client.PostAsJsonAsync("/api/verspakketten", request);
response.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<CreateVerspakket.Response>();
@@ -100,11 +121,44 @@ public class VerspakkettenControllerTests(LutraApiFactory factory)
response.Headers.Location.Should().NotBeNull();
}
[Fact]
public async Task Post_CreatesVerspakket_WithBeoordeling_AndReturns201()
{
var supermarkt = await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "Picnic",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var command = new CreateVerspakket.Command(
"Herfst Pakket",
1499,
3,
supermarkt.Id,
new Lutra.Application.Models.Verspakketten.Beoordeling
{
CijferSmaak = 9,
CijferBereiden = 8,
Aanbevolen = true,
Tekst = "Heel goed"
});
var response = await Client.PostAsJsonAsync("/api/verspakketten", command);
response.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<CreateVerspakket.Response>();
body!.Id.Should().NotBeEmpty();
var created = await Client.GetFromJsonAsync<GetVerspakket.Response>($"/api/verspakketten/{body.Id}");
created!.Verspakket.Beoordelingen.Should().ContainSingle();
created.Verspakket.Beoordelingen!.Single().CijferSmaak.Should().Be(9);
}
[Fact]
public async Task Post_ReturnsBadRequest_WhenSupermarktDoesNotExist()
{
var command = new CreateVerspakket.Command("Winter Pakket", 999, 2, Guid.NewGuid());
var response = await Client.PostAsJsonAsync("/api/verspakketten", command);
var request = new CreateVerspakketRequest("Winter Pakket", 999, 2, Guid.NewGuid());
var response = await Client.PostAsJsonAsync("/api/verspakketten", request);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
@@ -20,14 +20,14 @@ public abstract class IntegrationTestBase : IClassFixture<LutraApiFactory>, IAsy
}
/// <summary>Seed data or perform setup before each test.</summary>
public virtual Task InitializeAsync()
public virtual ValueTask InitializeAsync()
{
Factory.EnsureSchemaCreated();
return Task.CompletedTask;
return ValueTask.CompletedTask;
}
/// <summary>Reset database state after each test.</summary>
public async Task DisposeAsync()
public async ValueTask DisposeAsync()
{
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ILutraDbContext>() as LutraDbContext;
@@ -37,10 +37,10 @@ public abstract class IntegrationTestBase : IClassFixture<LutraApiFactory>, IAsy
db.Verspaketten.RemoveRange(db.Verspaketten);
db.Supermarkten.RemoveRange(db.Supermarkten);
await db.SaveChangesAsync(CancellationToken.None);
}
}
}
protected async Task<T> SeedAsync<T>(T entity) where T : class
protected async ValueTask<T> SeedAsync<T>(T entity) where T : class
{
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ILutraDbContext>() as LutraDbContext;
@@ -13,11 +13,14 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="8.9.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.6" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup>
<ItemGroup>
@@ -1,17 +1,12 @@
using Cortex.Mediator;
using Lutra.Application.Supermarkten;
using Lutra.Application.Verspakketten;
using Microsoft.AspNetCore.Mvc;
namespace Lutra.API.Controllers;
/// <summary>
/// Provides a dedicated endpoint group for Supermarkt-related operations.
/// Provides endpoints for Supermarkt-related operations.
/// </summary>
/// <remarks>
/// This controller is intentionally empty for now. Endpoints will be added when the Supermarkt
/// use cases are implemented.
/// </remarks>
[ApiController]
[Route("api/supermarkten")]
[Produces("application/json")]
@@ -22,7 +17,7 @@ public class SupermarktenController(IMediator mediator) : ControllerBase
/// </summary>
/// <param name="skip">The number of items to skip.</param>
/// <param name="take">The maximum number of items to return.</param>
/// <returns>The requested verspakket page.</returns>
/// <returns>The requested supermarkt page.</returns>
[HttpGet]
[ProducesResponseType(typeof(GetSupermarkten.Response), StatusCodes.Status200OK)]
public async Task<GetSupermarkten.Response> Get(int skip = 0, int take = 50)
@@ -3,134 +3,158 @@ using Lutra.API.Requests;
using Lutra.Application.Verspakketten;
using Microsoft.AspNetCore.Mvc;
namespace Lutra.API.Controllers
namespace Lutra.API.Controllers;
/// <summary>
/// Provides access to verspakket resources.
/// </summary>
[ApiController]
[Route("api/verspakketten")]
[Produces("application/json")]
public class VerspakkettenController(IMediator mediator) : ControllerBase
{
/// <summary>
/// Provides access to verspakket resources.
/// Gets a page of verspakketten.
/// </summary>
[ApiController]
[Route("api/verspakketten")]
[Produces("application/json")]
public class VerspakkettenController(IMediator mediator) : ControllerBase
/// <param name="skip">The number of items to skip. Default: 0.</param>
/// <param name="take">The maximum number of items to return. Default: 50.</param>
/// <param name="sortField">The field to sort by: Naam, PrijsInCenten, AverageCijferSmaak, or AverageCijferBereiden. Default: Naam.</param>
/// <param name="sortDirection">The sort direction: Ascending or Descending. Default: Ascending.</param>
/// <returns>The requested verspakket page.</returns>
[HttpGet]
[ProducesResponseType(typeof(GetVerspakketten.Response), StatusCodes.Status200OK)]
public async Task<GetVerspakketten.Response> Get(
int skip = 0,
int take = 50,
VerspakketSortField sortField = VerspakketSortField.Naam,
SortDirection sortDirection = SortDirection.Ascending)
{
/// <summary>
/// Gets a page of verspakketten.
/// </summary>
/// <param name="skip">The number of items to skip. Default: 0.</param>
/// <param name="take">The maximum number of items to return. Default: 50.</param>
/// <param name="sortField">The field to sort by: Naam, PrijsInCenten, AverageCijferSmaak, or AverageCijferBereiden. Default: Naam.</param>
/// <param name="sortDirection">The sort direction: Ascending or Descending. Default: Ascending.</param>
/// <returns>The requested verspakket page.</returns>
[HttpGet]
[ProducesResponseType(typeof(GetVerspakketten.Response), StatusCodes.Status200OK)]
public async Task<GetVerspakketten.Response> Get(
int skip = 0,
int take = 50,
VerspakketSortField sortField = VerspakketSortField.Naam,
SortDirection sortDirection = SortDirection.Ascending)
return await mediator.SendQueryAsync<GetVerspakketten.Query, GetVerspakketten.Response>(
new GetVerspakketten.Query(skip, take, sortField, sortDirection));
}
/// <summary>
/// Gets a specific verspakket by ID.
/// </summary>
/// <param name="id">The verspakket ID.</param>
/// <returns>Returns 200 OK with the verspakket when found, or 404 Not Found when not found.</returns>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(GetVerspakket.Response), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<GetVerspakket.Response?>> GetById(Guid id)
{
var result = await mediator.SendQueryAsync<GetVerspakket.Query, GetVerspakket.Response?>(new GetVerspakket.Query(id));
if (result?.Verspakket == null)
{
return await mediator.SendQueryAsync<GetVerspakketten.Query, GetVerspakketten.Response>(
new GetVerspakketten.Query(skip, take, sortField, sortDirection));
return NotFound();
}
/// <summary>
/// Gets a specific verspakket by ID.
/// </summary>
/// <param name="id">The verspakket ID.</param>
/// <returns>Returns 200 OK with the verspakket when found, or 404 Not Found when not found.</returns>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(GetVerspakket.Response), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<GetVerspakket.Response?>> GetById(Guid id)
{
var result = await mediator.SendQueryAsync<GetVerspakket.Query, GetVerspakket.Response?>(new GetVerspakket.Query(id));
if (result?.Verspakket == null)
{
return NotFound();
}
return Ok(result);
}
return Ok(result);
/// <summary>
/// Creates a new verspakket.
/// </summary>
/// <param name="request">The verspakket values to create.</param>
/// <returns>Returns 201 Created with the created verspakket identifier.</returns>
[HttpPost]
[ProducesResponseType(typeof(CreateVerspakket.Response), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<CreateVerspakket.Response>> Post([FromBody] CreateVerspakketRequest request)
{
try
{
var beoordeling = request.Beoordeling is null
? null
: new Application.Models.Verspakketten.Beoordeling
{
CijferSmaak = request.Beoordeling.CijferSmaak,
CijferBereiden = request.Beoordeling.CijferBereiden,
Aanbevolen = request.Beoordeling.Aanbevolen,
Tekst = request.Beoordeling.Tekst
};
var fotos = request.Fotos?
.Select(f => new Application.Models.Verspakketten.VerspakketFoto(f.Base64Data, f.IsMainImage))
.ToList();
var command = new CreateVerspakket.Command(
request.Naam,
request.PrijsInCenten,
request.AantalPersonen,
request.SupermarktId,
beoordeling,
fotos);
var result = await mediator.SendCommandAsync<CreateVerspakket.Command, CreateVerspakket.Response>(command);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
/// <summary>
/// Creates a new verspakket.
/// </summary>
/// <param name="command">The verspakket values to create.</param>
/// <returns>Returns 201 Created with the created verspakket identifier.</returns>
[HttpPost]
[ProducesResponseType(typeof(CreateVerspakket.Response), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<CreateVerspakket.Response>> Post([FromBody] CreateVerspakket.Command command)
catch (InvalidOperationException ex)
{
try
{
var result = await mediator.SendCommandAsync<CreateVerspakket.Command, CreateVerspakket.Response>(command);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
return BadRequest(ex.Message);
}
}
/// <summary>
/// Updates an existing verspakket with the provided values.
/// </summary>
/// <param name="id">The verspakket identifier.</param>
/// <param name="request">The updated verspakket values.</param>
/// <returns>
/// Returns 204 No Content when the update succeeds.
/// Returns 404 Not Found when the specified verspakket does not exist.
/// </returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateVerspakketRequest request)
/// <summary>
/// Updates an existing verspakket with the provided values.
/// </summary>
/// <param name="id">The verspakket identifier.</param>
/// <param name="request">The updated verspakket values.</param>
/// <returns>
/// Returns 204 No Content when the update succeeds.
/// Returns 404 Not Found when the specified verspakket does not exist.
/// </returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateVerspakketRequest request)
{
try
{
try
{
var command = new UpdateVerspakket.Command(id, request.Naam, request.PrijsInCenten, request.AantalPersonen, request.SupermarktId);
await mediator.SendCommandAsync<UpdateVerspakket.Command, UpdateVerspakket.Response>(command);
return NoContent();
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
catch (InvalidOperationException ex) when (ex.Message.StartsWith($"Verspakket with id '{id}'"))
{
return NotFound();
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
var fotos = request.Fotos?
.Select(f => new Application.Models.Verspakketten.VerspakketFoto(f.Base64Data, f.IsMainImage))
.ToList();
var command = new UpdateVerspakket.Command(id, request.Naam, request.PrijsInCenten, request.AantalPersonen, request.SupermarktId, fotos);
await mediator.SendCommandAsync<UpdateVerspakket.Command, UpdateVerspakket.Response>(command);
return NoContent();
}
/// <summary>
/// Adds a beoordeling to an existing verspakket.
/// </summary>
/// <param name="id">The verspakket ID.</param>
/// <param name="request">The beoordeling values to add.</param>
/// <returns>Returns 201 Created with the created beoordeling identifier.</returns>
[HttpPost("{id:guid}/beoordelingen")]
[ProducesResponseType(typeof(AddBeoordeling.Response), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<AddBeoordeling.Response>> AddBeoordeling(Guid id, [FromBody] AddBeoordelingRequest request)
catch (ArgumentException ex)
{
try
{
var command = new AddBeoordeling.Command(id, request.CijferSmaak, request.CijferBereiden, request.Aanbevolen, request.Tekst);
var result = await mediator.SendCommandAsync<AddBeoordeling.Command, AddBeoordeling.Response>(command);
return BadRequest(ex.Message);
}
catch (InvalidOperationException ex) when (ex.Message.StartsWith($"Verspakket with id '{id}'"))
{
return NotFound();
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
return CreatedAtAction(nameof(GetById), new { id }, result);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
/// <summary>
/// Adds a beoordeling to an existing verspakket.
/// </summary>
/// <param name="id">The verspakket ID.</param>
/// <param name="request">The beoordeling values to add.</param>
/// <returns>Returns 201 Created with the created beoordeling identifier.</returns>
[HttpPost("{id:guid}/beoordelingen")]
[ProducesResponseType(typeof(AddBeoordeling.Response), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<AddBeoordeling.Response>> AddBeoordeling(Guid id, [FromBody] AddBeoordelingRequest request)
{
try
{
var command = new AddBeoordeling.Command(id, request.CijferSmaak, request.CijferBereiden, request.Aanbevolen, request.Tekst);
var result = await mediator.SendCommandAsync<AddBeoordeling.Command, AddBeoordeling.Response>(command);
return CreatedAtAction(nameof(GetById), new { id }, result);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
# This stage is used when running from VS in fast mode (Default for Debug configuration)
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
@@ -9,7 +9,7 @@ EXPOSE 8081
# This stage is used to build the service project
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Lutra.API/Lutra.API.csproj", "Lutra.API/"]
+3 -3
View File
@@ -12,13 +12,13 @@
<ItemGroup>
<PackageReference Include="Cortex.Mediator" Version="3.1.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.6">
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.14.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.14.4" />
<ProjectReference Include="..\Lutra.Infrastructure\Lutra.Infrastructure.Sql.csproj" />
</ItemGroup>
+18
View File
@@ -14,6 +14,22 @@ namespace Lutra.API
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowLocalDevelopment", policy =>
policy.SetIsOriginAllowed(origin =>
{
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri))
{
return false;
}
return uri.Host is "localhost" or "127.0.0.1" or "[::1]";
})
.AllowAnyHeader()
.AllowAnyMethod());
});
builder.Services.AddCortexMediator(
handlerAssemblyMarkerTypes: [typeof(Program), typeof(GetVerspakketten)],
options => options.AddDefaultBehaviors()
@@ -37,6 +53,8 @@ namespace Lutra.API
app.UseHttpsRedirection();
app.UseCors("AllowLocalDevelopment");
app.UseAuthorization();
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace Lutra.API.Requests;
/// <summary>
/// Represents the data required to create a verspakket.
/// </summary>
public sealed record CreateVerspakketRequest(
[Required, MaxLength(50)] string Naam,
[Range(0, int.MaxValue)] int? PrijsInCenten,
[Range(1, 10)] int AantalPersonen,
[Required] Guid SupermarktId,
AddBeoordelingRequest? Beoordeling = null,
IReadOnlyList<VerspakketFotoRequest>? Fotos = null);
@@ -9,4 +9,7 @@ public sealed record UpdateVerspakketRequest(
[Required, MaxLength(50)] string Naam,
[Range(0, int.MaxValue)] int PrijsInCenten,
[Range(1, 10)] int AantalPersonen,
[Required] Guid SupermarktId);
[Required] Guid SupermarktId,
IReadOnlyList<VerspakketFotoRequest>? Fotos = null);
@@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace Lutra.API.Requests;
/// <summary>
/// Represents a foto in a request, encoded as base64.
/// </summary>
public sealed record VerspakketFotoRequest(
[Required] string Base64Data,
bool IsMainImage);
@@ -1,4 +1,7 @@
{
"ConnectionStrings": {
"LutraDb": "Host=localhost;Database=lutra;Username=postgres;Password=<set-locally>"
},
"Logging": {
"LogLevel": {
"Default": "Information",
+1 -1
View File
@@ -6,7 +6,7 @@
}
},
"ConnectionStrings": {
"LutraDb": "Host=db.m91.nl;Username=user;Password=password;Database=Lutra"
"LutraDb": ""
},
"AllowedHosts": "*"
}
+2 -2
View File
@@ -11,8 +11,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.2.2" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.2.2" />
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.2.3" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.2.3" />
</ItemGroup>
<ItemGroup>
@@ -8,13 +8,19 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="coverlet.collector" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="8.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Moq.EntityFrameworkCore" Version="10.0.0.2" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup>
<ItemGroup>
@@ -1,5 +1,6 @@
using FluentAssertions;
using Lutra.Application.Interfaces;
using Lutra.Application.Models.Verspakketten;
using Lutra.Application.Verspakketten;
using Moq;
using Moq.EntityFrameworkCore;
@@ -30,7 +31,7 @@ public class CreateVerspakketHandlerTests
_contextMock.Setup(c => c.Verspaketten).ReturnsDbSet(new List<Domain.Entities.Verspakket>());
_contextMock.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
var command = new CreateVerspakket.Command("Lente Pakket", 1299, 2, supermarktId);
var command = new CreateVerspakket.Command("Lente Pakket", 1299, 2, supermarktId, null);
var result = await _handler.Handle(command, CancellationToken.None);
@@ -43,7 +44,7 @@ public class CreateVerspakketHandlerTests
{
_contextMock.Setup(c => c.Supermarkten).ReturnsDbSet(new List<Domain.Entities.Supermarkt>());
var command = new CreateVerspakket.Command("Lente Pakket", 1299, 2, Guid.NewGuid());
var command = new CreateVerspakket.Command("Lente Pakket", 1299, 2, Guid.NewGuid(), null);
var act = () => _handler.Handle(command, CancellationToken.None);
@@ -68,7 +69,7 @@ public class CreateVerspakketHandlerTests
.Callback<Domain.Entities.Verspakket, CancellationToken>((v, _) => savedVerspakket = v);
_contextMock.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
var command = new CreateVerspakket.Command("Zomer Pakket", 999, 4, supermarktId);
var command = new CreateVerspakket.Command("Zomer Pakket", 999, 4, supermarktId, null);
await _handler.Handle(command, CancellationToken.None);
@@ -78,4 +79,45 @@ public class CreateVerspakketHandlerTests
savedVerspakket.AantalPersonen.Should().Be(4);
savedVerspakket.SupermarktId.Should().Be(supermarktId);
}
[Fact]
public async Task Handle_WithBeoordeling_CreatesVerspakketWithBeoordeling()
{
var supermarktId = Guid.NewGuid();
var supermarkten = new List<Domain.Entities.Supermarkt>
{
new() { Id = supermarktId, Naam = "Jumbo", CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow }
};
Domain.Entities.Verspakket? savedVerspakket = null;
_contextMock.Setup(c => c.Supermarkten).ReturnsDbSet(supermarkten);
_contextMock.Setup(c => c.Verspaketten).ReturnsDbSet(new List<Domain.Entities.Verspakket>());
_contextMock
.Setup(c => c.Verspaketten.AddAsync(It.IsAny<Domain.Entities.Verspakket>(), It.IsAny<CancellationToken>()))
.Callback<Domain.Entities.Verspakket, CancellationToken>((v, _) => savedVerspakket = v);
_contextMock.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
var command = new CreateVerspakket.Command(
"Zomer Pakket",
999,
4,
supermarktId,
new Beoordeling
{
CijferSmaak = 8,
CijferBereiden = 7,
Aanbevolen = true,
Tekst = "Lekker"
});
await _handler.Handle(command, CancellationToken.None);
savedVerspakket.Should().NotBeNull();
savedVerspakket!.Beoordelingen.Should().ContainSingle();
var beoordeling = savedVerspakket.Beoordelingen.Single();
beoordeling.CijferSmaak.Should().Be(8);
beoordeling.CijferBereiden.Should().Be(7);
beoordeling.Aanbevolen.Should().BeTrue();
beoordeling.Tekst.Should().Be("Lekker");
}
}
@@ -0,0 +1,99 @@
using FluentAssertions;
using Lutra.Application.Interfaces;
using Lutra.Application.Models.Verspakketten;
using Lutra.Application.Verspakketten;
using Moq;
using Moq.EntityFrameworkCore;
namespace Lutra.Application.UnitTests.Verspakketten;
public class CreateVerspakketWithFotosHandlerTests
{
private readonly Mock<ILutraDbContext> _contextMock;
private readonly CreateVerspakket.Handler _handler;
// 1x1 white PNG as base64
private const string ValidBase64Png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI6QAAAABJRU5ErkJggg==";
public CreateVerspakketWithFotosHandlerTests()
{
_contextMock = new Mock<ILutraDbContext>();
_handler = new CreateVerspakket.Handler(_contextMock.Object);
}
private void SetupContext(Guid supermarktId)
{
var supermarkten = new List<Domain.Entities.Supermarkt>
{
new() { Id = supermarktId, Naam = "AH", CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow }
};
_contextMock.Setup(c => c.Supermarkten).ReturnsDbSet(supermarkten);
_contextMock.Setup(c => c.Verspaketten).ReturnsDbSet(new List<Domain.Entities.Verspakket>());
_contextMock.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
}
[Fact]
public async Task Handle_WithFotos_CreatesVerspakketWithFotos()
{
var supermarktId = Guid.NewGuid();
SetupContext(supermarktId);
Domain.Entities.Verspakket? saved = null;
_contextMock
.Setup(c => c.Verspaketten.AddAsync(It.IsAny<Domain.Entities.Verspakket>(), It.IsAny<CancellationToken>()))
.Callback<Domain.Entities.Verspakket, CancellationToken>((v, _) => saved = v);
var fotos = new List<VerspakketFoto>
{
new(ValidBase64Png, IsMainImage: true),
new(ValidBase64Png, IsMainImage: false)
};
var command = new CreateVerspakket.Command("Lente Pakket", 999, 2, supermarktId, null, fotos);
await _handler.Handle(command, CancellationToken.None);
saved.Should().NotBeNull();
saved!.Fotos.Should().HaveCount(2);
saved.Fotos.Count(f => f.IsMainImage).Should().Be(1);
}
[Fact]
public async Task Handle_WithoutFotos_CreatesVerspakketWithNoFotos()
{
var supermarktId = Guid.NewGuid();
SetupContext(supermarktId);
Domain.Entities.Verspakket? saved = null;
_contextMock
.Setup(c => c.Verspaketten.AddAsync(It.IsAny<Domain.Entities.Verspakket>(), It.IsAny<CancellationToken>()))
.Callback<Domain.Entities.Verspakket, CancellationToken>((v, _) => saved = v);
var command = new CreateVerspakket.Command("Herfst Pakket", 799, 2, supermarktId, null);
await _handler.Handle(command, CancellationToken.None);
saved.Should().NotBeNull();
saved!.Fotos.Should().BeEmpty();
}
[Fact]
public async Task Handle_FotoBase64Decoded_StoresCorrectBytes()
{
var supermarktId = Guid.NewGuid();
SetupContext(supermarktId);
Domain.Entities.Verspakket? saved = null;
_contextMock
.Setup(c => c.Verspaketten.AddAsync(It.IsAny<Domain.Entities.Verspakket>(), It.IsAny<CancellationToken>()))
.Callback<Domain.Entities.Verspakket, CancellationToken>((v, _) => saved = v);
var fotos = new List<VerspakketFoto> { new(ValidBase64Png, IsMainImage: false) };
var command = new CreateVerspakket.Command("Pakket", null, 1, supermarktId, null, fotos);
await _handler.Handle(command, CancellationToken.None);
var foto = saved!.Fotos.Single();
foto.Data.Should().BeEquivalentTo(Convert.FromBase64String(ValidBase64Png));
}
}
@@ -0,0 +1,141 @@
using FluentAssertions;
using Lutra.Application.Interfaces;
using Lutra.Application.Models.Verspakketten;
using Lutra.Application.Verspakketten;
using Moq;
using Moq.EntityFrameworkCore;
namespace Lutra.Application.UnitTests.Verspakketten;
public class UpdateVerspakketHandlerTests
{
private readonly Mock<ILutraDbContext> _contextMock;
private readonly UpdateVerspakket.Handler _handler;
private const string ValidBase64Png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI6QAAAABJRU5ErkJggg==";
public UpdateVerspakketHandlerTests()
{
_contextMock = new Mock<ILutraDbContext>();
_handler = new UpdateVerspakket.Handler(_contextMock.Object);
}
private (Guid verspakketId, Guid supermarktId) SetupContext(
List<Domain.Entities.VerspakketFoto>? existingFotos = null)
{
var supermarktId = Guid.NewGuid();
var verspakketId = Guid.NewGuid();
var supermarkten = new List<Domain.Entities.Supermarkt>
{
new() { Id = supermarktId, Naam = "AH", CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow }
};
var verspakket = new Domain.Entities.Verspakket
{
Id = verspakketId,
Naam = "Oud Pakket",
PrijsInCenten = 500,
AantalPersonen = 2,
SupermarktId = supermarktId,
CreatedAt = DateTime.UtcNow,
ModifiedAt = DateTime.UtcNow
};
foreach (var foto in existingFotos ?? [])
verspakket.AddFoto(foto);
_contextMock.Setup(c => c.Supermarkten).ReturnsDbSet(supermarkten);
_contextMock.Setup(c => c.Verspaketten).ReturnsDbSet(new List<Domain.Entities.Verspakket> { verspakket });
_contextMock.Setup(c => c.VerspakketFotos).ReturnsDbSet(existingFotos ?? []);
_contextMock.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
return (verspakketId, supermarktId);
}
[Fact]
public async Task Handle_WithoutFotos_UpdatesFieldsOnly()
{
var (verspakketId, supermarktId) = SetupContext();
var command = new UpdateVerspakket.Command(verspakketId, "Nieuw Pakket", 1200, 4, supermarktId);
var result = await _handler.Handle(command, CancellationToken.None);
result.Should().NotBeNull();
_contextMock.Verify(c => c.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_WithFotos_ReplacesFotos()
{
var oldFotoId = Guid.NewGuid();
var existingFotos = new List<Domain.Entities.VerspakketFoto>
{
new()
{
Id = oldFotoId,
Data = [0x00],
IsMainImage = true,
VerspakketId = Guid.NewGuid(),
CreatedAt = DateTime.UtcNow,
ModifiedAt = DateTime.UtcNow
}
};
var (verspakketId, supermarktId) = SetupContext(existingFotos);
var newFotos = new List<VerspakketFoto>
{
new(ValidBase64Png, IsMainImage: true),
new(ValidBase64Png, IsMainImage: false)
};
var command = new UpdateVerspakket.Command(verspakketId, "Pakket", 999, 2, supermarktId, newFotos);
await _handler.Handle(command, CancellationToken.None);
_contextMock.Verify(c => c.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_NullFotos_DoesNotTouchFotos()
{
var (verspakketId, supermarktId) = SetupContext();
var command = new UpdateVerspakket.Command(verspakketId, "Pakket", 800, 3, supermarktId, null);
await _handler.Handle(command, CancellationToken.None);
// VerspakketFotos.RemoveRange should NOT be called when Fotos is null
_contextMock.Verify(
c => c.VerspakketFotos.RemoveRange(It.IsAny<IEnumerable<Domain.Entities.VerspakketFoto>>()),
Times.Never);
}
[Fact]
public async Task Handle_VerspakketNotFound_ThrowsInvalidOperationException()
{
var supermarktId = Guid.NewGuid();
_contextMock.Setup(c => c.Verspaketten).ReturnsDbSet(new List<Domain.Entities.Verspakket>());
_contextMock.Setup(c => c.Supermarkten).ReturnsDbSet(new List<Domain.Entities.Supermarkt>());
var command = new UpdateVerspakket.Command(Guid.NewGuid(), "Pakket", 800, 2, supermarktId);
var act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>().WithMessage("*was not found*");
}
[Fact]
public async Task Handle_InvalidAantalPersonen_ThrowsArgumentException()
{
var (verspakketId, supermarktId) = SetupContext();
var command = new UpdateVerspakket.Command(verspakketId, "Pakket", 800, 0, supermarktId);
var act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<ArgumentException>();
}
}
@@ -1,5 +1,6 @@
using Lutra.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace Lutra.Application.Interfaces;
public interface ILutraDbContext
@@ -8,6 +9,8 @@ public interface ILutraDbContext
DbSet<Beoordeling> Beoordelingen { get; }
DbSet<VerspakketFoto> VerspakketFotos { get; }
DbSet<Verspakket> Verspaketten { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
@@ -8,7 +8,7 @@
<ItemGroup>
<PackageReference Include="Cortex.Mediator" Version="3.1.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
</ItemGroup>
<ItemGroup>
@@ -1,9 +1,8 @@
namespace Lutra.Application.Models.Supermarkten
{
public record Supermarkt
{
public required Guid Id { get; init; }
namespace Lutra.Application.Models.Supermarkten;
public required string Naam { get; init; }
}
public record Supermarkt
{
public required Guid Id { get; init; }
public required string Naam { get; init; }
}
@@ -1,6 +1,6 @@
namespace Lutra.Application.Models.Verspakketten;
public class Beoordeling
public record Beoordeling
{
public required int CijferSmaak { get; init; }
@@ -1,23 +1,24 @@
using Lutra.Application.Models.Supermarkten;
namespace Lutra.Application.Models.Verspakketten
namespace Lutra.Application.Models.Verspakketten;
public record Verspakket
{
public record Verspakket
{
public required Guid Id { get; init; }
public required Guid Id { get; init; }
public required string Naam { get; init; }
public required string Naam { get; init; }
public int? PrijsInCenten { get; init; }
public int? PrijsInCenten { get; init; }
public int AantalPersonen { get; init; }
public int AantalPersonen { get; init; }
public double? AverageCijferSmaak { get; init; }
public double? AverageCijferBereiden { get; init; }
public Beoordeling[]? Beoordelingen { get; init; }
public Beoordeling[]? Beoordelingen { get; init; }
public Supermarkt? Supermarkt { get; init; }
}
public VerspakketFotoResponse[]? Fotos { get; init; }
public Supermarkt? Supermarkt { get; init; }
}
@@ -0,0 +1,9 @@
namespace Lutra.Application.Models.Verspakketten;
/// <summary>
/// Represents a foto to associate with a verspakket.
/// </summary>
public sealed record VerspakketFoto(
/// <summary>Base64-encoded image data.</summary>
string Base64Data,
bool IsMainImage);
@@ -0,0 +1,7 @@
namespace Lutra.Application.Models.Verspakketten;
public sealed record VerspakketFotoResponse(
Guid Id,
/// <summary>Base64-encoded image data.</summary>
string Base64Data,
bool IsMainImage);
@@ -0,0 +1,22 @@
using Lutra.Application.Models.Supermarkten;
namespace Lutra.Application.Models.Verspakketten;
public record VerspakketSummary
{
public required Guid Id { get; init; }
public required string Naam { get; init; }
public int? PrijsInCenten { get; init; }
public int AantalPersonen { get; init; }
public double? AverageCijferSmaak { get; init; }
public double? AverageCijferBereiden { get; init; }
public VerspakketFotoResponse? Foto { get; init; }
public Supermarkt? Supermarkt { get; init; }
}
@@ -3,29 +3,28 @@ using Lutra.Application.Interfaces;
using Lutra.Application.Models.Supermarkten;
using Microsoft.EntityFrameworkCore;
namespace Lutra.Application.Supermarkten
{
public sealed partial class GetSupermarkten
{
public sealed class Handler(ILutraDbContext context) : IQueryHandler<Query, Response>
{
public async Task<Response> Handle(Query request, CancellationToken cancellationToken)
{
var supermarkten = await context.Supermarkten
.AsNoTracking()
.Where(w => w.DeletedAt == null)
.OrderBy(s => s.Naam)
.Skip(request.Skip)
.Take(request.Take)
.Select(s => new Supermarkt
{
Id = s.Id,
Naam = s.Naam
})
.ToListAsync(cancellationToken);
namespace Lutra.Application.Supermarkten;
return new Response { Supermarkten = supermarkten };
}
public sealed partial class GetSupermarkten
{
public sealed class Handler(ILutraDbContext context) : IQueryHandler<Query, Response>
{
public async Task<Response> Handle(Query request, CancellationToken cancellationToken)
{
var supermarkten = await context.Supermarkten
.AsNoTracking()
.Where(w => w.DeletedAt == null)
.OrderBy(s => s.Naam)
.Skip(request.Skip)
.Take(request.Take)
.Select(s => new Supermarkt
{
Id = s.Id,
Naam = s.Naam
})
.ToListAsync(cancellationToken);
return new Response { Supermarkten = supermarkten };
}
}
}
@@ -1,9 +1,8 @@
using Cortex.Mediator.Queries;
namespace Lutra.Application.Supermarkten
namespace Lutra.Application.Supermarkten;
public sealed partial class GetSupermarkten
{
public sealed partial class GetSupermarkten
{
public record Query(int Skip, int Take) : IQuery<Response>;
}
public record Query(int Skip, int Take) : IQuery<Response>;
}
@@ -1,12 +1,11 @@
using Lutra.Application.Models.Supermarkten;
namespace Lutra.Application.Supermarkten
namespace Lutra.Application.Supermarkten;
public sealed partial class GetSupermarkten
{
public sealed partial class GetSupermarkten
public sealed record Response
{
public sealed class Response
{
public required IEnumerable<Supermarkt> Supermarkten { get; set; }
}
public required IEnumerable<Supermarkt> Supermarkten { get; init; }
}
}
@@ -1,4 +1,3 @@
namespace Lutra.Application.Supermarkten
{
public sealed partial class GetSupermarkten { }
}
namespace Lutra.Application.Supermarkten;
public sealed partial class GetSupermarkten { }
@@ -2,8 +2,8 @@ namespace Lutra.Application.Verspakketten;
public sealed partial class AddBeoordeling
{
public sealed class Response
public sealed record Response
{
public required Guid Id { get; set; }
public required Guid Id { get; init; }
}
}
@@ -1,5 +1,5 @@
using Cortex.Mediator.Commands;
using System.ComponentModel.DataAnnotations;
using Lutra.Application.Models.Verspakketten;
namespace Lutra.Application.Verspakketten;
@@ -8,6 +8,8 @@ public sealed partial class CreateVerspakket
public sealed record Command(
string Naam,
int? PrijsInCenten,
[Range(1, 10)] int AantalPersonen,
Guid SupermarktId) : ICommand<Response>;
int AantalPersonen,
Guid SupermarktId,
Beoordeling? Beoordeling,
IReadOnlyList<VerspakketFoto>? Fotos = null) : ICommand<Response>;
}
@@ -1,5 +1,6 @@
using Cortex.Mediator.Commands;
using Lutra.Application.Interfaces;
using Lutra.Application.Models.Verspakketten;
using Microsoft.EntityFrameworkCore;
namespace Lutra.Application.Verspakketten;
@@ -31,6 +32,37 @@ public sealed partial class CreateVerspakket
ModifiedAt = now
};
if (request.Beoordeling is not null)
{
verspakket.AddBeoordeling(new Domain.Entities.Beoordeling
{
Id = Guid.NewGuid(),
CijferSmaak = request.Beoordeling.CijferSmaak,
CijferBereiden = request.Beoordeling.CijferBereiden,
Aanbevolen = request.Beoordeling.Aanbevolen,
Tekst = request.Beoordeling.Tekst,
VerspakketId = verspakket.Id,
CreatedAt = now,
ModifiedAt = now
});
}
if (request.Fotos is { Count: > 0 })
{
foreach (var foto in request.Fotos)
{
verspakket.AddFoto(new Domain.Entities.VerspakketFoto
{
Id = Guid.NewGuid(),
Data = Convert.FromBase64String(foto.Base64Data),
IsMainImage = foto.IsMainImage,
VerspakketId = verspakket.Id,
CreatedAt = now,
ModifiedAt = now
});
}
}
await context.Verspaketten.AddAsync(verspakket, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
@@ -2,8 +2,8 @@ namespace Lutra.Application.Verspakketten;
public sealed partial class CreateVerspakket
{
public sealed class Response
public sealed record Response
{
public required Guid Id { get; set; }
public required Guid Id { get; init; }
}
}
@@ -4,44 +4,49 @@ using Lutra.Application.Models.Supermarkten;
using Lutra.Application.Models.Verspakketten;
using Microsoft.EntityFrameworkCore;
namespace Lutra.Application.Verspakketten
{
namespace Lutra.Application.Verspakketten;
public sealed partial class GetVerspakket
{
public sealed class Handler(ILutraDbContext context) : IQueryHandler<Query, Response?>
{
public async Task<Response?> Handle(Query request, CancellationToken cancellationToken)
{
var verspakket = await context.Verspaketten
.AsNoTracking()
.Where(v => v.Id == request.Id)
.Select(v => new Verspakket
{
Id = v.Id,
Naam = v.Naam,
PrijsInCenten = v.PrijsInCenten,
AantalPersonen = v.AantalPersonen,
AverageCijferSmaak = v.Beoordelingen.Any() ? v.Beoordelingen.Average(b => (double)b.CijferSmaak) : null,
AverageCijferBereiden = v.Beoordelingen.Any() ? v.Beoordelingen.Average(b => (double)b.CijferBereiden) : null,
Beoordelingen = v.Beoordelingen
.Select(b => new Beoordeling
{
CijferSmaak = b.CijferSmaak,
CijferBereiden = b.CijferBereiden,
Aanbevolen = b.Aanbevolen,
Tekst = b.Tekst
})
.ToArray(),
Supermarkt = new Supermarkt
{
Id = v.Supermarkt.Id,
Naam = v.Supermarkt.Naam
}
})
.SingleOrDefaultAsync(cancellationToken);
var verspakket = await context.Verspaketten
.AsNoTracking()
.Where(v => v.Id == request.Id)
.Select(v => new Verspakket
{
Id = v.Id,
Naam = v.Naam,
PrijsInCenten = v.PrijsInCenten,
AantalPersonen = v.AantalPersonen,
AverageCijferSmaak = v.Beoordelingen.Any() ? v.Beoordelingen.Average(b => (double)b.CijferSmaak) : null,
AverageCijferBereiden = v.Beoordelingen.Any() ? v.Beoordelingen.Average(b => (double)b.CijferBereiden) : null,
Beoordelingen = v.Beoordelingen
.Select(b => new Beoordeling
{
CijferSmaak = b.CijferSmaak,
CijferBereiden = b.CijferBereiden,
Aanbevolen = b.Aanbevolen,
Tekst = b.Tekst
})
.ToArray(),
Supermarkt = new Supermarkt
{
Id = v.Supermarkt.Id,
Naam = v.Supermarkt.Naam
},
Fotos = v.Fotos
.Select(f => new VerspakketFotoResponse(
f.Id,
Convert.ToBase64String(f.Data),
f.IsMainImage))
.ToArray()
})
.SingleOrDefaultAsync(cancellationToken);
return verspakket is null ? null : new Response { Verspakket = verspakket };
}
}
}
}
return verspakket is null ? null : new Response { Verspakket = verspakket };
}
}
}
@@ -1,9 +1,8 @@
using Cortex.Mediator.Queries;
namespace Lutra.Application.Verspakketten
namespace Lutra.Application.Verspakketten;
public sealed partial class GetVerspakket
{
public sealed partial class GetVerspakket
{
public record Query(Guid Id) : IQuery<Response?>;
}
public record Query(Guid Id) : IQuery<Response?>;
}
@@ -1,12 +1,11 @@
using Lutra.Application.Models.Verspakketten;
namespace Lutra.Application.Verspakketten
namespace Lutra.Application.Verspakketten;
public sealed partial class GetVerspakket
{
public sealed partial class GetVerspakket
public sealed record Response
{
public sealed class Response
{
public required Verspakket Verspakket { get; set; }
}
public required Verspakket Verspakket { get; init; }
}
}
@@ -1,4 +1,3 @@
namespace Lutra.Application.Verspakketten
{
public sealed partial class GetVerspakket { }
}
namespace Lutra.Application.Verspakketten;
public sealed partial class GetVerspakket { }
@@ -4,69 +4,66 @@ using Lutra.Application.Models.Supermarkten;
using Lutra.Application.Models.Verspakketten;
using Microsoft.EntityFrameworkCore;
namespace Lutra.Application.Verspakketten
namespace Lutra.Application.Verspakketten;
public sealed partial class GetVerspakketten
{
public sealed partial class GetVerspakketten
public sealed class Handler(ILutraDbContext context) : IQueryHandler<Query, Response>
{
public sealed class Handler(ILutraDbContext context) : IQueryHandler<Query, Response>
public async Task<Response> Handle(Query request, CancellationToken cancellationToken)
{
public async Task<Response> Handle(Query request, CancellationToken cancellationToken)
var query = context.Verspaketten
.Where(w => w.DeletedAt == null)
.AsNoTracking();
// Apply sort before pagination so the database handles ordering efficiently.
IOrderedQueryable<Domain.Entities.Verspakket> sorted = request.SortField switch
{
var query = context.Verspaketten
.Where(w => w.DeletedAt == null)
.AsNoTracking();
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),
};
// Apply sort before pagination so the database handles ordering efficiently.
IOrderedQueryable<Domain.Entities.Verspakket> sorted = request.SortField switch
var verspakketten = await sorted
.Skip(request.Skip)
.Take(request.Take)
.Select(v => new VerspakketSummary
{
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
Id = v.Id,
Naam = v.Naam,
PrijsInCenten = v.PrijsInCenten,
AantalPersonen = v.AantalPersonen,
AverageCijferSmaak = v.Beoordelingen.Any() ? v.Beoordelingen.Average(b => (double)b.CijferSmaak) : null,
AverageCijferBereiden = v.Beoordelingen.Any() ? v.Beoordelingen.Average(b => (double)b.CijferBereiden) : null,
Supermarkt = new Supermarkt
{
Id = v.Id,
Naam = v.Naam,
PrijsInCenten = v.PrijsInCenten,
AantalPersonen = v.AantalPersonen,
AverageCijferSmaak = v.Beoordelingen.Any() ? v.Beoordelingen.Average(b => (double)b.CijferSmaak) : null,
AverageCijferBereiden = v.Beoordelingen.Any() ? v.Beoordelingen.Average(b => (double)b.CijferBereiden) : null,
Beoordelingen = v.Beoordelingen
.Select(b => new Beoordeling
{
CijferSmaak = b.CijferSmaak,
CijferBereiden = b.CijferBereiden,
Aanbevolen = b.Aanbevolen,
Tekst = b.Tekst
})
.ToArray(),
Supermarkt = new Supermarkt
{
Id = v.Supermarkt.Id,
Naam = v.Supermarkt.Naam
}
})
.ToListAsync(cancellationToken);
Id = v.Supermarkt.Id,
Naam = v.Supermarkt.Naam
},
Foto = v.Fotos
.Where(f => f.IsMainImage)
.Select(f => new VerspakketFotoResponse(
f.Id,
Convert.ToBase64String(f.Data),
f.IsMainImage))
.SingleOrDefault()
})
.ToListAsync(cancellationToken);
return new Response { Verspakketten = verspakketten };
}
return new Response { Verspakketten = verspakketten };
}
}
}
@@ -1,13 +1,12 @@
using Cortex.Mediator.Queries;
namespace Lutra.Application.Verspakketten
namespace Lutra.Application.Verspakketten;
public sealed partial class GetVerspakketten
{
public sealed partial class GetVerspakketten
{
public record Query(
int Skip,
int Take,
VerspakketSortField SortField = VerspakketSortField.Naam,
SortDirection SortDirection = SortDirection.Ascending) : IQuery<Response>;
}
public record Query(
int Skip,
int Take,
VerspakketSortField SortField = VerspakketSortField.Naam,
SortDirection SortDirection = SortDirection.Ascending) : IQuery<Response>;
}
@@ -1,12 +1,11 @@
using Lutra.Application.Models.Verspakketten;
namespace Lutra.Application.Verspakketten
namespace Lutra.Application.Verspakketten;
public sealed partial class GetVerspakketten
{
public sealed partial class GetVerspakketten
public sealed record Response
{
public sealed class Response
{
public required IEnumerable<Verspakket> Verspakketten { get; set; }
}
public required IEnumerable<VerspakketSummary> Verspakketten { get; init; }
}
}
@@ -1,4 +1,3 @@
namespace Lutra.Application.Verspakketten
{
public sealed partial class GetVerspakketten { }
}
namespace Lutra.Application.Verspakketten;
public sealed partial class GetVerspakketten { }
@@ -1,4 +1,5 @@
using Cortex.Mediator.Commands;
using Lutra.Application.Models.Verspakketten;
namespace Lutra.Application.Verspakketten;
@@ -7,5 +8,11 @@ public sealed partial class UpdateVerspakket
/// <summary>
/// Updates an existing verspakket.
/// </summary>
public sealed record Command(Guid Id, string Naam, int PrijsInCenten, int AantalPersonen, Guid SupermarktId) : ICommand<Response>;
public sealed record Command(
Guid Id,
string Naam,
int PrijsInCenten,
int AantalPersonen,
Guid SupermarktId,
IReadOnlyList<VerspakketFoto>? Fotos = null) : ICommand<Response>;
}
@@ -32,6 +32,7 @@ public sealed partial class UpdateVerspakket
throw new ArgumentException("AantalPersonen moet tussen 1 en 10 liggen.", nameof(request.AantalPersonen));
var verspakket = await context.Verspaketten
.Include(v => v.Fotos)
.FirstOrDefaultAsync(v => v.Id == request.Id && v.DeletedAt == null, cancellationToken);
if (verspakket is null)
@@ -54,6 +55,32 @@ public sealed partial class UpdateVerspakket
verspakket.SupermarktId = request.SupermarktId;
verspakket.ModifiedAt = DateTime.UtcNow;
if (request.Fotos is not null)
{
// Replace all existing fotos
foreach (var existing in verspakket.Fotos.ToList())
verspakket.RemoveFoto(existing.Id);
context.VerspakketFotos.RemoveRange(
await context.VerspakketFotos
.Where(f => f.VerspakketId == request.Id)
.ToListAsync(cancellationToken));
var now = DateTime.UtcNow;
foreach (var foto in request.Fotos)
{
verspakket.AddFoto(new Domain.Entities.VerspakketFoto
{
Id = Guid.NewGuid(),
Data = Convert.FromBase64String(foto.Base64Data),
IsMainImage = foto.IsMainImage,
VerspakketId = verspakket.Id,
CreatedAt = now,
ModifiedAt = now
});
}
}
await context.SaveChangesAsync(cancellationToken);
return new Response();
+3 -1
View File
@@ -4,9 +4,11 @@ public abstract class BaseEntity
{
public Guid Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime ModifiedAt { get; set; }
public DateTime? DeletedAt { get; set; }
public bool IsDeleted => DeletedAt.HasValue;
}
@@ -16,4 +16,6 @@ public class Beoordeling : BaseEntity
public string? Tekst { get; set; }
public required Guid VerspakketId { get; set; }
public virtual Verspakket Verspakket { get; set; } = null!;
}
+19 -1
View File
@@ -5,6 +5,7 @@ namespace Lutra.Domain.Entities;
public class Verspakket : BaseEntity
{
private readonly List<Beoordeling> _beoordelingen = [];
private readonly List<VerspakketFoto> _fotos = [];
[MaxLength(50)]
public required string Naam { get; set; }
@@ -13,7 +14,7 @@ public class Verspakket : BaseEntity
public int? PrijsInCenten { get; set; }
[Range(1, 10)]
public int AantalPersonen { get; set; }
public required int AantalPersonen { get; set; }
public required Guid SupermarktId { get; set; }
@@ -21,11 +22,18 @@ public class Verspakket : BaseEntity
public IReadOnlyCollection<Beoordeling> Beoordelingen => _beoordelingen.AsReadOnly();
public IReadOnlyCollection<VerspakketFoto> Fotos => _fotos.AsReadOnly();
public void AddBeoordeling(Beoordeling beoordeling)
{
_beoordelingen.Add(beoordeling);
}
public void AddFoto(VerspakketFoto foto)
{
_fotos.Add(foto);
}
public bool RemoveBeoordeling(Guid id)
{
var beoordeling = _beoordelingen.Find(b => b.Id == id);
@@ -35,4 +43,14 @@ public class Verspakket : BaseEntity
_beoordelingen.Remove(beoordeling);
return true;
}
public bool RemoveFoto(Guid id)
{
var foto = _fotos.Find(f => f.Id == id);
if (foto is null)
return false;
_fotos.Remove(foto);
return true;
}
}
@@ -0,0 +1,12 @@
namespace Lutra.Domain.Entities;
public class VerspakketFoto : BaseEntity
{
public required byte[] Data { get; set; }
public required bool IsMainImage { get; set; }
public required Guid VerspakketId { get; set; }
public virtual Verspakket Verspakket { get; set; } = null!;
}
@@ -8,8 +8,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.6" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.6" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
</ItemGroup>
<ItemGroup>
@@ -7,12 +7,16 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.6">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
</ItemGroup>
+32 -2
View File
@@ -15,22 +15,33 @@ public class LutraDbContext : DbContext, ILutraDbContext
public DbSet<Beoordeling> Beoordelingen => Set<Beoordeling>();
public DbSet<VerspakketFoto> VerspakketFotos => Set<VerspakketFoto>();
public DbSet<Verspakket> Verspaketten => Set<Verspakket>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Beoordeling>()
.ToTable("Beoordelingen");
// Global soft-delete filter: exclude logically deleted entities from all queries.
modelBuilder.Entity<Beoordeling>().HasQueryFilter(b => !b.IsDeleted);
modelBuilder.Entity<VerspakketFoto>().HasQueryFilter(f => !f.IsDeleted);
modelBuilder.Entity<Supermarkt>().HasQueryFilter(s => !s.IsDeleted);
modelBuilder.Entity<Verspakket>(b =>
{
b.HasQueryFilter(v => !v.IsDeleted);
b.HasMany(v => v.Beoordelingen)
.WithOne()
.HasForeignKey(beo => beo.VerspakketId)
.IsRequired();
b.HasMany(v => v.Fotos)
.WithOne()
.HasForeignKey(foto => foto.VerspakketId)
.IsRequired();
b.ToTable(t =>
{
t.HasCheckConstraint("CK_Verspaketten_AantalPersonen", "\"AantalPersonen\" BETWEEN 1 AND 10");
@@ -39,8 +50,27 @@ public class LutraDbContext : DbContext, ILutraDbContext
});
}
/// <summary>
/// Populates audit fields on tracked entities before persisting.
/// </summary>
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
foreach (var entry in ChangeTracker.Entries<BaseEntity>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.ModifiedAt = now;
break;
case EntityState.Modified:
entry.Entity.ModifiedAt = now;
break;
}
}
return base.SaveChangesAsync(cancellationToken);
}
}
@@ -1,20 +1,58 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
namespace Lutra.Infrastructure.Sql;
/// <summary>
/// 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.
/// Reads ConnectionStrings:LutraDb from the API project's appsettings.json.
/// </summary>
internal sealed class LutraDbContextFactory : IDesignTimeDbContextFactory<LutraDbContext>
{
private const string ConnectionStringName = "LutraDb";
private const string ConnectionStringEnvironmentVariableName = "ConnectionStrings__LutraDb";
public LutraDbContext CreateDbContext(string[] args)
{
var apiProjectPath = GetApiProjectPath();
var configuration = new ConfigurationBuilder()
.SetBasePath(apiProjectPath)
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile("appsettings.Development.json", optional: true)
.AddEnvironmentVariables()
.Build();
var connectionString = configuration.GetConnectionString(ConnectionStringName);
if (string.IsNullOrWhiteSpace(connectionString) || connectionString.Contains("<set-locally>", StringComparison.Ordinal))
{
throw new InvalidOperationException($"ConnectionStrings:{ConnectionStringName} is not configured. Set it in appsettings.Development.json or via {ConnectionStringEnvironmentVariableName}.");
}
var options = new DbContextOptionsBuilder<LutraDbContext>()
.UseNpgsql("Host=localhost;Database=lutra;Username=postgres;Password=postgres")
.UseNpgsql(connectionString)
.Options;
return new LutraDbContext(options);
}
private static string GetApiProjectPath()
{
var currentDirectory = new DirectoryInfo(Directory.GetCurrentDirectory());
while (currentDirectory is not null)
{
var apiProjectPath = Path.Combine(currentDirectory.FullName, "Lutra.API");
if (Directory.Exists(apiProjectPath))
{
return apiProjectPath;
}
currentDirectory = currentDirectory.Parent;
}
throw new InvalidOperationException("Could not locate the Lutra.API project directory.");
}
}
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lutra.Infrastructure.Sql.Migrations
{
/// <inheritdoc />
public partial class AddVerspakketFotos : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "VerspakketFotos",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ModifiedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Data = table.Column<byte[]>(type: "bytea", nullable: false),
VerspakketId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VerspakketFotos", x => x.Id);
table.ForeignKey(
name: "FK_VerspakketFotos_Verspaketten_VerspakketId",
column: x => x.VerspakketId,
principalTable: "Verspaketten",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_VerspakketFotos_VerspakketId",
table: "VerspakketFotos",
column: "VerspakketId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "VerspakketFotos");
}
}
}
@@ -0,0 +1,201 @@
// <auto-generated />
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("20260425121000_AddIsMainImageToVerspakketFoto")]
partial class AddIsMainImageToVerspakketFoto
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Aanbevolen")
.HasColumnType("boolean");
b.Property<int>("CijferBereiden")
.HasColumnType("integer");
b.Property<int>("CijferSmaak")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Tekst")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("VerspakketId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("VerspakketId");
b.ToTable("Beoordelingen", (string)null);
});
modelBuilder.Entity("Lutra.Domain.Entities.Supermarkt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.ToTable("Supermarkten");
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AantalPersonen")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int?>("PrijsInCenten")
.HasColumnType("integer");
b.Property<Guid>("SupermarktId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SupermarktId");
b.ToTable("Verspaketten", t =>
{
t.HasCheckConstraint("CK_Verspaketten_AantalPersonen", "\"AantalPersonen\" BETWEEN 1 AND 10");
t.HasCheckConstraint("CK_Verspaketten_PrijsInCenten", "\"PrijsInCenten\" IS NULL OR \"PrijsInCenten\" >= 0");
});
});
modelBuilder.Entity("Lutra.Domain.Entities.VerspakketFoto", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<byte[]>("Data")
.IsRequired()
.HasColumnType("bytea");
b.Property<bool>("IsMainImage")
.HasColumnType("boolean");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("VerspakketId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("VerspakketId");
b.ToTable("VerspakketFotos");
});
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.VerspakketFoto", b =>
{
b.HasOne("Lutra.Domain.Entities.Verspakket", null)
.WithMany("Fotos")
.HasForeignKey("VerspakketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.Navigation("Beoordelingen");
b.Navigation("Fotos");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lutra.Infrastructure.Sql.Migrations
{
/// <inheritdoc />
public partial class AddIsMainImageToVerspakketFoto : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsMainImage",
table: "VerspakketFotos",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsMainImage",
table: "VerspakketFotos");
}
}
}
@@ -0,0 +1,201 @@
// <auto-generated />
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("20260425194247_ReplaceVerspakketFotosWithSingleMainImage")]
partial class ReplaceVerspakketFotosWithSingleMainImage
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Aanbevolen")
.HasColumnType("boolean");
b.Property<int>("CijferBereiden")
.HasColumnType("integer");
b.Property<int>("CijferSmaak")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Tekst")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("VerspakketId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("VerspakketId");
b.ToTable("Beoordelingen", (string)null);
});
modelBuilder.Entity("Lutra.Domain.Entities.Supermarkt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.ToTable("Supermarkten");
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AantalPersonen")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int?>("PrijsInCenten")
.HasColumnType("integer");
b.Property<Guid>("SupermarktId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SupermarktId");
b.ToTable("Verspaketten", t =>
{
t.HasCheckConstraint("CK_Verspaketten_AantalPersonen", "\"AantalPersonen\" BETWEEN 1 AND 10");
t.HasCheckConstraint("CK_Verspaketten_PrijsInCenten", "\"PrijsInCenten\" IS NULL OR \"PrijsInCenten\" >= 0");
});
});
modelBuilder.Entity("Lutra.Domain.Entities.VerspakketFoto", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("Data")
.IsRequired()
.HasColumnType("bytea");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("VerspakketId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("VerspakketId")
.IsUnique();
b.ToTable("VerspakketFotos", (string)null);
});
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.VerspakketFoto", b =>
{
b.HasOne("Lutra.Domain.Entities.Verspakket", "Verspakket")
.WithOne("MainImage")
.HasForeignKey("Lutra.Domain.Entities.VerspakketFoto", "VerspakketId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Verspakket");
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.Navigation("Beoordelingen");
b.Navigation("MainImage");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lutra.Infrastructure.Sql.Migrations
{
/// <inheritdoc />
public partial class ReplaceVerspakketFotosWithSingleMainImage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_VerspakketFotos_VerspakketId",
table: "VerspakketFotos");
migrationBuilder.DropColumn(
name: "IsMainImage",
table: "VerspakketFotos");
migrationBuilder.CreateIndex(
name: "IX_VerspakketFotos_VerspakketId",
table: "VerspakketFotos",
column: "VerspakketId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_VerspakketFotos_VerspakketId",
table: "VerspakketFotos");
migrationBuilder.AddColumn<bool>(
name: "IsMainImage",
table: "VerspakketFotos",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateIndex(
name: "IX_VerspakketFotos_VerspakketId",
table: "VerspakketFotos",
column: "VerspakketId");
}
}
}
@@ -17,7 +17,7 @@ namespace Lutra.Infrastructure.Sql.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.6")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -126,6 +126,36 @@ namespace Lutra.Infrastructure.Sql.Migrations
});
});
modelBuilder.Entity("Lutra.Domain.Entities.VerspakketFoto", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("Data")
.IsRequired()
.HasColumnType("bytea");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("VerspakketId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("VerspakketId")
.IsUnique();
b.ToTable("VerspakketFotos", (string)null);
});
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
{
b.HasOne("Lutra.Domain.Entities.Verspakket", null)
@@ -146,9 +176,21 @@ namespace Lutra.Infrastructure.Sql.Migrations
b.Navigation("Supermarkt");
});
modelBuilder.Entity("Lutra.Domain.Entities.VerspakketFoto", b =>
{
b.HasOne("Lutra.Domain.Entities.Verspakket", "Verspakket")
.WithOne("MainImage")
.HasForeignKey("Lutra.Domain.Entities.VerspakketFoto", "VerspakketId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Verspakket");
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.Navigation("Beoordelingen");
b.Navigation("MainImage");
});
#pragma warning restore 612, 618
}
+88
View File
@@ -0,0 +1,88 @@
# Lutra API
REST API for Verspakketten, built with .NET 10 and PostgreSQL.
## Prerequisites
- [Docker](https://docs.docker.com/get-docker/) installed and running
- A PostgreSQL database reachable from the container
## Running with Docker
### 1. Build the image
Run this command from the **root of the repository** (where `Lutra.sln` lives), because the
Dockerfile copies the full solution source tree during the build:
```bash
docker build -f Lutra.API/Dockerfile -t lutra-api .
```
### 2. Run the container
Supply the PostgreSQL connection string via the `ConnectionStrings__LutraDb` environment variable:
```bash
docker run -d \
--name lutra-api \
-p 8080:8080 \
-e ConnectionStrings__LutraDb="Host=<host>;Username=<user>;Password=<password>;Database=Lutra" \
lutra-api
```
> **How it works:** ASP.NET Core maps environment variables to configuration keys by replacing
> `__` with `:`, so `ConnectionStrings__LutraDb` is equivalent to `ConnectionStrings:LutraDb`
> in `appsettings.json`. No code changes are needed.
The API will be available at `http://localhost:8080`.
### 3. (Optional) docker-compose
Create a `docker-compose.yml` alongside this README and adjust the values to your environment:
```yaml
services:
api:
image: lutra-api
build:
context: .
dockerfile: Lutra.API/Dockerfile
ports:
- "8080:8080"
environment:
- ConnectionStrings__LutraDb=Host=db;Username=user;Password=secret;Database=Lutra
depends_on:
- db
db:
image: postgres:17
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: secret
POSTGRES_DB: Lutra
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
```
Then run:
```bash
docker compose up -d
```
## Configuration reference
All settings can be overridden with environment variables using the `__` separator.
| Environment variable | Default | Description |
|---|---|---|
| `ConnectionStrings__LutraDb` | *(empty)* | Full Npgsql connection string to the PostgreSQL database |
| `ASPNETCORE_ENVIRONMENT` | `Production` | Set to `Development` to enable Scalar API docs at `/scalar/v1` |
## Development
See the individual project READMEs or the `.github/copilot-instructions.md` for architecture
and contribution guidelines.