diff --git a/Lutra/.github/copilot-instructions.md b/Lutra/.github/copilot-instructions.md index 50e3da5..ae69146 100644 --- a/Lutra/.github/copilot-instructions.md +++ b/Lutra/.github/copilot-instructions.md @@ -88,6 +88,7 @@ Add FluentValidation, AutoMapper, Shouldly, Moq, or Respawn only if the reposito - 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 diff --git a/Lutra/Lutra.API.IntegrationTests/Controllers/VerspakkettenControllerTests.cs b/Lutra/Lutra.API.IntegrationTests/Controllers/VerspakkettenControllerTests.cs index eca8272..6b96077 100644 --- a/Lutra/Lutra.API.IntegrationTests/Controllers/VerspakkettenControllerTests.cs +++ b/Lutra/Lutra.API.IntegrationTests/Controllers/VerspakkettenControllerTests.cs @@ -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(); 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, null); - 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(); @@ -136,8 +157,8 @@ public class VerspakkettenControllerTests(LutraApiFactory factory) [Fact] public async Task Post_ReturnsBadRequest_WhenSupermarktDoesNotExist() { - var command = new CreateVerspakket.Command("Winter Pakket", 999, 2, Guid.NewGuid(), null); - 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); } diff --git a/Lutra/Lutra.API.IntegrationTests/Infrastructure/IntegrationTestBase.cs b/Lutra/Lutra.API.IntegrationTests/Infrastructure/IntegrationTestBase.cs index 779ac84..536a16b 100644 --- a/Lutra/Lutra.API.IntegrationTests/Infrastructure/IntegrationTestBase.cs +++ b/Lutra/Lutra.API.IntegrationTests/Infrastructure/IntegrationTestBase.cs @@ -20,14 +20,14 @@ public abstract class IntegrationTestBase : IClassFixture, IAsy } /// Seed data or perform setup before each test. - public virtual Task InitializeAsync() + public virtual ValueTask InitializeAsync() { Factory.EnsureSchemaCreated(); - return Task.CompletedTask; + return ValueTask.CompletedTask; } /// Reset database state after each test. - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService() as LutraDbContext; @@ -37,10 +37,10 @@ public abstract class IntegrationTestBase : IClassFixture, IAsy db.Verspaketten.RemoveRange(db.Verspaketten); db.Supermarkten.RemoveRange(db.Supermarkten); await db.SaveChangesAsync(CancellationToken.None); - } + } } - protected async Task SeedAsync(T entity) where T : class + protected async ValueTask SeedAsync(T entity) where T : class { using var scope = Factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService() as LutraDbContext; diff --git a/Lutra/Lutra.API.IntegrationTests/Lutra.API.IntegrationTests.csproj b/Lutra/Lutra.API.IntegrationTests/Lutra.API.IntegrationTests.csproj index 139006b..c9e69a7 100644 --- a/Lutra/Lutra.API.IntegrationTests/Lutra.API.IntegrationTests.csproj +++ b/Lutra/Lutra.API.IntegrationTests/Lutra.API.IntegrationTests.csproj @@ -13,11 +13,14 @@ all - - - - - + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/Lutra/Lutra.API/Controllers/SupermarktenController.cs b/Lutra/Lutra.API/Controllers/SupermarktenController.cs index b15c83d..4f70d3f 100644 --- a/Lutra/Lutra.API/Controllers/SupermarktenController.cs +++ b/Lutra/Lutra.API/Controllers/SupermarktenController.cs @@ -5,12 +5,8 @@ using Microsoft.AspNetCore.Mvc; namespace Lutra.API.Controllers; /// -/// Provides a dedicated endpoint group for Supermarkt-related operations. +/// Provides endpoints for Supermarkt-related operations. /// -/// -/// This controller is intentionally empty for now. Endpoints will be added when the Supermarkt -/// use cases are implemented. -/// [ApiController] [Route("api/supermarkten")] [Produces("application/json")] @@ -21,7 +17,7 @@ public class SupermarktenController(IMediator mediator) : ControllerBase /// /// The number of items to skip. /// The maximum number of items to return. - /// The requested verspakket page. + /// The requested supermarkt page. [HttpGet] [ProducesResponseType(typeof(GetSupermarkten.Response), StatusCodes.Status200OK)] public async Task Get(int skip = 0, int take = 50) diff --git a/Lutra/Lutra.API/Controllers/VerspakkettenController.cs b/Lutra/Lutra.API/Controllers/VerspakkettenController.cs index 8608aff..12b2689 100644 --- a/Lutra/Lutra.API/Controllers/VerspakkettenController.cs +++ b/Lutra/Lutra.API/Controllers/VerspakkettenController.cs @@ -3,134 +3,158 @@ using Lutra.API.Requests; using Lutra.Application.Verspakketten; using Microsoft.AspNetCore.Mvc; -namespace Lutra.API.Controllers +namespace Lutra.API.Controllers; + +/// +/// Provides access to verspakket resources. +/// +[ApiController] +[Route("api/verspakketten")] +[Produces("application/json")] +public class VerspakkettenController(IMediator mediator) : ControllerBase { /// - /// Provides access to verspakket resources. + /// Gets a page of verspakketten. /// - [ApiController] - [Route("api/verspakketten")] - [Produces("application/json")] - public class VerspakkettenController(IMediator mediator) : ControllerBase + /// 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, + VerspakketSortField sortField = VerspakketSortField.Naam, + SortDirection sortDirection = SortDirection.Ascending) { - /// - /// Gets a page of verspakketten. - /// - /// 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, - VerspakketSortField sortField = VerspakketSortField.Naam, - SortDirection sortDirection = SortDirection.Ascending) + 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 await mediator.SendQueryAsync( - new GetVerspakketten.Query(skip, take, sortField, sortDirection)); + return NotFound(); } - /// - /// 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); + } - return Ok(result); + /// + /// Creates a new verspakket. + /// + /// The verspakket values to create. + /// Returns 201 Created with the created verspakket identifier. + [HttpPost] + [ProducesResponseType(typeof(CreateVerspakket.Response), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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(command); + + return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); } - - /// - /// Creates a new verspakket. - /// - /// The verspakket values to create. - /// Returns 201 Created with the created verspakket identifier. - [HttpPost] - [ProducesResponseType(typeof(CreateVerspakket.Response), StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task> Post([FromBody] CreateVerspakket.Command command) + catch (InvalidOperationException ex) { - try - { - var result = await mediator.SendCommandAsync(command); - - return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); - } - catch (InvalidOperationException ex) - { - return BadRequest(ex.Message); - } + return BadRequest(ex.Message); } + } - /// - /// Updates an existing verspakket with the provided values. - /// - /// The verspakket identifier. - /// The updated verspakket values. - /// - /// Returns 204 No Content when the update succeeds. - /// Returns 404 Not Found when the specified verspakket does not exist. - /// - [HttpPut("{id:guid}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task Update(Guid id, [FromBody] UpdateVerspakketRequest request) + /// + /// Updates an existing verspakket with the provided values. + /// + /// The verspakket identifier. + /// The updated verspakket values. + /// + /// Returns 204 No Content when the update succeeds. + /// Returns 404 Not Found when the specified verspakket does not exist. + /// + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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(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(command); + return NoContent(); } - - /// - /// Adds a beoordeling to an existing verspakket. - /// - /// The verspakket ID. - /// The beoordeling values to add. - /// Returns 201 Created with the created beoordeling identifier. - [HttpPost("{id:guid}/beoordelingen")] - [ProducesResponseType(typeof(AddBeoordeling.Response), StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task> 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(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); - } + /// + /// Adds a beoordeling to an existing verspakket. + /// + /// The verspakket ID. + /// The beoordeling values to add. + /// Returns 201 Created with the created beoordeling identifier. + [HttpPost("{id:guid}/beoordelingen")] + [ProducesResponseType(typeof(AddBeoordeling.Response), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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(command); + + return CreatedAtAction(nameof(GetById), new { id }, result); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); } } } \ No newline at end of file diff --git a/Lutra/Lutra.API/Dockerfile b/Lutra/Lutra.API/Dockerfile index 1a9a012..5494719 100644 --- a/Lutra/Lutra.API/Dockerfile +++ b/Lutra/Lutra.API/Dockerfile @@ -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/"] diff --git a/Lutra/Lutra.API/Lutra.API.csproj b/Lutra/Lutra.API/Lutra.API.csproj index 4441b7e..b990d77 100644 --- a/Lutra/Lutra.API/Lutra.API.csproj +++ b/Lutra/Lutra.API/Lutra.API.csproj @@ -12,13 +12,13 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/Lutra/Lutra.API/Requests/CreateVerspakketRequest.cs b/Lutra/Lutra.API/Requests/CreateVerspakketRequest.cs new file mode 100644 index 0000000..a843395 --- /dev/null +++ b/Lutra/Lutra.API/Requests/CreateVerspakketRequest.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Lutra.API.Requests; + +/// +/// Represents the data required to create a verspakket. +/// +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? Fotos = null); \ No newline at end of file diff --git a/Lutra/Lutra.API/Requests/UpdateVerspakketRequest.cs b/Lutra/Lutra.API/Requests/UpdateVerspakketRequest.cs index 375ff52..6d6e12a 100644 --- a/Lutra/Lutra.API/Requests/UpdateVerspakketRequest.cs +++ b/Lutra/Lutra.API/Requests/UpdateVerspakketRequest.cs @@ -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? Fotos = null); + + diff --git a/Lutra/Lutra.API/Requests/VerspakketFotoRequest.cs b/Lutra/Lutra.API/Requests/VerspakketFotoRequest.cs new file mode 100644 index 0000000..dd23c84 --- /dev/null +++ b/Lutra/Lutra.API/Requests/VerspakketFotoRequest.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace Lutra.API.Requests; + +/// +/// Represents a foto in a request, encoded as base64. +/// +public sealed record VerspakketFotoRequest( + [Required] string Base64Data, + bool IsMainImage); \ No newline at end of file diff --git a/Lutra/Lutra.API/appsettings.Development.json b/Lutra/Lutra.API/appsettings.Development.json index 0c208ae..db211b4 100644 --- a/Lutra/Lutra.API/appsettings.Development.json +++ b/Lutra/Lutra.API/appsettings.Development.json @@ -1,4 +1,7 @@ { + "ConnectionStrings": { + "LutraDb": "Host=localhost;Database=lutra;Username=postgres;Password=" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/Lutra/Lutra.API/appsettings.json b/Lutra/Lutra.API/appsettings.json index 18a8f11..1eb62ea 100644 --- a/Lutra/Lutra.API/appsettings.json +++ b/Lutra/Lutra.API/appsettings.json @@ -6,7 +6,7 @@ } }, "ConnectionStrings": { - "LutraDb": "Host=db.m91.nl;Username=user;Password=password;Database=Lutra" + "LutraDb": "" }, "AllowedHosts": "*" } diff --git a/Lutra/Lutra.AppHost/Lutra.AppHost.csproj b/Lutra/Lutra.AppHost/Lutra.AppHost.csproj index 8ea44f4..0432977 100644 --- a/Lutra/Lutra.AppHost/Lutra.AppHost.csproj +++ b/Lutra/Lutra.AppHost/Lutra.AppHost.csproj @@ -11,8 +11,8 @@ - - + + diff --git a/Lutra/Lutra.Application.UnitTests/Lutra.Application.UnitTests.csproj b/Lutra/Lutra.Application.UnitTests/Lutra.Application.UnitTests.csproj index e8d1d29..bdd9960 100644 --- a/Lutra/Lutra.Application.UnitTests/Lutra.Application.UnitTests.csproj +++ b/Lutra/Lutra.Application.UnitTests/Lutra.Application.UnitTests.csproj @@ -8,13 +8,19 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - + - - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/Lutra/Lutra.Application.UnitTests/Verspakketten/CreateVerspakketWithFotosHandlerTests.cs b/Lutra/Lutra.Application.UnitTests/Verspakketten/CreateVerspakketWithFotosHandlerTests.cs new file mode 100644 index 0000000..3743a5f --- /dev/null +++ b/Lutra/Lutra.Application.UnitTests/Verspakketten/CreateVerspakketWithFotosHandlerTests.cs @@ -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 _contextMock; + private readonly CreateVerspakket.Handler _handler; + + // 1x1 white PNG as base64 + private const string ValidBase64Png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI6QAAAABJRU5ErkJggg=="; + + public CreateVerspakketWithFotosHandlerTests() + { + _contextMock = new Mock(); + _handler = new CreateVerspakket.Handler(_contextMock.Object); + } + + private void SetupContext(Guid supermarktId) + { + var supermarkten = new List + { + 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()); + _contextMock.Setup(c => c.SaveChangesAsync(It.IsAny())).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(), It.IsAny())) + .Callback((v, _) => saved = v); + + var fotos = new List + { + 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(), It.IsAny())) + .Callback((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(), It.IsAny())) + .Callback((v, _) => saved = v); + + var fotos = new List { 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)); + } +} diff --git a/Lutra/Lutra.Application.UnitTests/Verspakketten/UpdateVerspakketHandlerTests.cs b/Lutra/Lutra.Application.UnitTests/Verspakketten/UpdateVerspakketHandlerTests.cs new file mode 100644 index 0000000..160dd9c --- /dev/null +++ b/Lutra/Lutra.Application.UnitTests/Verspakketten/UpdateVerspakketHandlerTests.cs @@ -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 _contextMock; + private readonly UpdateVerspakket.Handler _handler; + + private const string ValidBase64Png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI6QAAAABJRU5ErkJggg=="; + + public UpdateVerspakketHandlerTests() + { + _contextMock = new Mock(); + _handler = new UpdateVerspakket.Handler(_contextMock.Object); + } + + private (Guid verspakketId, Guid supermarktId) SetupContext( + List? existingFotos = null) + { + var supermarktId = Guid.NewGuid(); + var verspakketId = Guid.NewGuid(); + + var supermarkten = new List + { + 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 { verspakket }); + _contextMock.Setup(c => c.VerspakketFotos).ReturnsDbSet(existingFotos ?? []); + _contextMock.Setup(c => c.SaveChangesAsync(It.IsAny())).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()), Times.Once); + } + + [Fact] + public async Task Handle_WithFotos_ReplacesFotos() + { + var oldFotoId = Guid.NewGuid(); + var existingFotos = new List + { + 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 + { + 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()), 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>()), + Times.Never); + } + + [Fact] + public async Task Handle_VerspakketNotFound_ThrowsInvalidOperationException() + { + var supermarktId = Guid.NewGuid(); + _contextMock.Setup(c => c.Verspaketten).ReturnsDbSet(new List()); + _contextMock.Setup(c => c.Supermarkten).ReturnsDbSet(new List()); + + var command = new UpdateVerspakket.Command(Guid.NewGuid(), "Pakket", 800, 2, supermarktId); + + var act = () => _handler.Handle(command, CancellationToken.None); + + await act.Should().ThrowAsync().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(); + } +} diff --git a/Lutra/Lutra.Application/Interfaces/ILutraDbContext.cs b/Lutra/Lutra.Application/Interfaces/ILutraDbContext.cs index e9807db..8da4010 100644 --- a/Lutra/Lutra.Application/Interfaces/ILutraDbContext.cs +++ b/Lutra/Lutra.Application/Interfaces/ILutraDbContext.cs @@ -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 Beoordelingen { get; } + DbSet VerspakketFotos { get; } + DbSet Verspaketten { get; } Task SaveChangesAsync(CancellationToken cancellationToken); diff --git a/Lutra/Lutra.Application/Lutra.Application.csproj b/Lutra/Lutra.Application/Lutra.Application.csproj index 109f6fd..a4c934e 100644 --- a/Lutra/Lutra.Application/Lutra.Application.csproj +++ b/Lutra/Lutra.Application/Lutra.Application.csproj @@ -8,7 +8,7 @@ - + diff --git a/Lutra/Lutra.Application/Models/Supermarkten/Supermarkt.cs b/Lutra/Lutra.Application/Models/Supermarkten/Supermarkt.cs index 31c2398..4b6553b 100644 --- a/Lutra/Lutra.Application/Models/Supermarkten/Supermarkt.cs +++ b/Lutra/Lutra.Application/Models/Supermarkten/Supermarkt.cs @@ -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; } } diff --git a/Lutra/Lutra.Application/Models/Verspakketten/Beoordeling.cs b/Lutra/Lutra.Application/Models/Verspakketten/Beoordeling.cs index 84f8f1e..1a25962 100644 --- a/Lutra/Lutra.Application/Models/Verspakketten/Beoordeling.cs +++ b/Lutra/Lutra.Application/Models/Verspakketten/Beoordeling.cs @@ -1,17 +1,12 @@ -using System.ComponentModel.DataAnnotations; +namespace Lutra.Application.Models.Verspakketten; -namespace Lutra.Application.Models.Verspakketten; - -public class Beoordeling +public record Beoordeling { - [Range(1, 10)] public required int CijferSmaak { get; init; } - [Range(1, 10)] public required int CijferBereiden { get; init; } public required bool Aanbevolen { get; init; } - [MaxLength(1024)] public string? Tekst { get; init; } } diff --git a/Lutra/Lutra.Application/Models/Verspakketten/Verspakket.cs b/Lutra/Lutra.Application/Models/Verspakketten/Verspakket.cs index e07938a..9af1fb0 100644 --- a/Lutra/Lutra.Application/Models/Verspakketten/Verspakket.cs +++ b/Lutra/Lutra.Application/Models/Verspakketten/Verspakket.cs @@ -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; } } diff --git a/Lutra/Lutra.Application/Models/Verspakketten/VerspakketFoto.cs b/Lutra/Lutra.Application/Models/Verspakketten/VerspakketFoto.cs new file mode 100644 index 0000000..78bc6be --- /dev/null +++ b/Lutra/Lutra.Application/Models/Verspakketten/VerspakketFoto.cs @@ -0,0 +1,9 @@ +namespace Lutra.Application.Models.Verspakketten; + +/// +/// Represents a foto to associate with a verspakket. +/// +public sealed record VerspakketFoto( + /// Base64-encoded image data. + string Base64Data, + bool IsMainImage); diff --git a/Lutra/Lutra.Application/Models/Verspakketten/VerspakketFotoResponse.cs b/Lutra/Lutra.Application/Models/Verspakketten/VerspakketFotoResponse.cs new file mode 100644 index 0000000..fbe183b --- /dev/null +++ b/Lutra/Lutra.Application/Models/Verspakketten/VerspakketFotoResponse.cs @@ -0,0 +1,7 @@ +namespace Lutra.Application.Models.Verspakketten; + +public sealed record VerspakketFotoResponse( + Guid Id, + /// Base64-encoded image data. + string Base64Data, + bool IsMainImage); diff --git a/Lutra/Lutra.Application/Models/Verspakketten/VerspakketSummary.cs b/Lutra/Lutra.Application/Models/Verspakketten/VerspakketSummary.cs new file mode 100644 index 0000000..7008911 --- /dev/null +++ b/Lutra/Lutra.Application/Models/Verspakketten/VerspakketSummary.cs @@ -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; } +} \ No newline at end of file diff --git a/Lutra/Lutra.Application/Supermarkten/GetSupermarkten.Handler.cs b/Lutra/Lutra.Application/Supermarkten/GetSupermarkten.Handler.cs index 756e766..760b310 100644 --- a/Lutra/Lutra.Application/Supermarkten/GetSupermarkten.Handler.cs +++ b/Lutra/Lutra.Application/Supermarkten/GetSupermarkten.Handler.cs @@ -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 - { - public async Task 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 + { + public async Task 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 }; } } } diff --git a/Lutra/Lutra.Application/Supermarkten/GetSupermarkten.Query.cs b/Lutra/Lutra.Application/Supermarkten/GetSupermarkten.Query.cs index c308800..7527e3a 100644 --- a/Lutra/Lutra.Application/Supermarkten/GetSupermarkten.Query.cs +++ b/Lutra/Lutra.Application/Supermarkten/GetSupermarkten.Query.cs @@ -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; - } + public record Query(int Skip, int Take) : IQuery; } diff --git a/Lutra/Lutra.Application/Supermarkten/GetSupermarkten.Response.cs b/Lutra/Lutra.Application/Supermarkten/GetSupermarkten.Response.cs index e292d65..a8ecbfb 100644 --- a/Lutra/Lutra.Application/Supermarkten/GetSupermarkten.Response.cs +++ b/Lutra/Lutra.Application/Supermarkten/GetSupermarkten.Response.cs @@ -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 Supermarkten { get; set; } - } + public required IEnumerable Supermarkten { get; init; } } } diff --git a/Lutra/Lutra.Application/Supermarkten/GetSupermarkten.cs b/Lutra/Lutra.Application/Supermarkten/GetSupermarkten.cs index 1a96d6e..e29eebf 100644 --- a/Lutra/Lutra.Application/Supermarkten/GetSupermarkten.cs +++ b/Lutra/Lutra.Application/Supermarkten/GetSupermarkten.cs @@ -1,4 +1,3 @@ -namespace Lutra.Application.Supermarkten -{ - public sealed partial class GetSupermarkten { } -} +namespace Lutra.Application.Supermarkten; + +public sealed partial class GetSupermarkten { } diff --git a/Lutra/Lutra.Application/Verspakketten/AddBeoordeling.Response.cs b/Lutra/Lutra.Application/Verspakketten/AddBeoordeling.Response.cs index c039e06..722aac9 100644 --- a/Lutra/Lutra.Application/Verspakketten/AddBeoordeling.Response.cs +++ b/Lutra/Lutra.Application/Verspakketten/AddBeoordeling.Response.cs @@ -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; } } } \ No newline at end of file diff --git a/Lutra/Lutra.Application/Verspakketten/CreateVerspakket.Command.cs b/Lutra/Lutra.Application/Verspakketten/CreateVerspakket.Command.cs index ce31fc2..418fdea 100644 --- a/Lutra/Lutra.Application/Verspakketten/CreateVerspakket.Command.cs +++ b/Lutra/Lutra.Application/Verspakketten/CreateVerspakket.Command.cs @@ -1,6 +1,5 @@ using Cortex.Mediator.Commands; using Lutra.Application.Models.Verspakketten; -using System.ComponentModel.DataAnnotations; namespace Lutra.Application.Verspakketten; @@ -9,7 +8,8 @@ public sealed partial class CreateVerspakket public sealed record Command( string Naam, int? PrijsInCenten, - [Range(1, 10)] int AantalPersonen, + int AantalPersonen, Guid SupermarktId, - Beoordeling? Beoordeling) : ICommand; + Beoordeling? Beoordeling, + IReadOnlyList? Fotos = null) : ICommand; } diff --git a/Lutra/Lutra.Application/Verspakketten/CreateVerspakket.Handler.cs b/Lutra/Lutra.Application/Verspakketten/CreateVerspakket.Handler.cs index c459515..f945d95 100644 --- a/Lutra/Lutra.Application/Verspakketten/CreateVerspakket.Handler.cs +++ b/Lutra/Lutra.Application/Verspakketten/CreateVerspakket.Handler.cs @@ -47,6 +47,22 @@ public sealed partial class CreateVerspakket }); } + 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); diff --git a/Lutra/Lutra.Application/Verspakketten/CreateVerspakket.Response.cs b/Lutra/Lutra.Application/Verspakketten/CreateVerspakket.Response.cs index e2c3f91..d619214 100644 --- a/Lutra/Lutra.Application/Verspakketten/CreateVerspakket.Response.cs +++ b/Lutra/Lutra.Application/Verspakketten/CreateVerspakket.Response.cs @@ -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; } } } diff --git a/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Handler.cs b/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Handler.cs index 8beaa20..b8713d1 100644 --- a/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Handler.cs +++ b/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Handler.cs @@ -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 { public async Task 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 }; + } + } + } diff --git a/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Query.cs b/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Query.cs index af44c30..cd91cf4 100644 --- a/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Query.cs +++ b/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Query.cs @@ -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; - } + public record Query(Guid Id) : IQuery; } diff --git a/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Response.cs b/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Response.cs index 83f78d0..c421714 100644 --- a/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Response.cs +++ b/Lutra/Lutra.Application/Verspakketten/GetVerspakket.Response.cs @@ -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; } } } diff --git a/Lutra/Lutra.Application/Verspakketten/GetVerspakket.cs b/Lutra/Lutra.Application/Verspakketten/GetVerspakket.cs index 19039f6..df54bc0 100644 --- a/Lutra/Lutra.Application/Verspakketten/GetVerspakket.cs +++ b/Lutra/Lutra.Application/Verspakketten/GetVerspakket.cs @@ -1,4 +1,3 @@ -namespace Lutra.Application.Verspakketten -{ - public sealed partial class GetVerspakket { } -} +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 fae33d9..97922c0 100644 --- a/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Handler.cs +++ b/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Handler.cs @@ -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 { - public sealed class Handler(ILutraDbContext context) : IQueryHandler + public async Task Handle(Query request, CancellationToken cancellationToken) { - public async Task 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 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 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 }; } } } diff --git a/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Query.cs b/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Query.cs index 2188a6b..ce7b69f 100644 --- a/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Query.cs +++ b/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Query.cs @@ -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; - } + public record Query( + int Skip, + int Take, + VerspakketSortField SortField = VerspakketSortField.Naam, + SortDirection SortDirection = SortDirection.Ascending) : IQuery; } diff --git a/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Response.cs b/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Response.cs index d471dc3..03dc0a2 100644 --- a/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Response.cs +++ b/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.Response.cs @@ -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 Verspakketten { get; set; } - } + public required IEnumerable Verspakketten { get; init; } } } diff --git a/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.cs b/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.cs index ce89c33..884de38 100644 --- a/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.cs +++ b/Lutra/Lutra.Application/Verspakketten/GetVerspakketten.cs @@ -1,4 +1,3 @@ -namespace Lutra.Application.Verspakketten -{ - public sealed partial class GetVerspakketten { } -} +namespace Lutra.Application.Verspakketten; + +public sealed partial class GetVerspakketten { } diff --git a/Lutra/Lutra.Application/Verspakketten/UpdateVerspakket.Command.cs b/Lutra/Lutra.Application/Verspakketten/UpdateVerspakket.Command.cs index 08aa023..6f7e190 100644 --- a/Lutra/Lutra.Application/Verspakketten/UpdateVerspakket.Command.cs +++ b/Lutra/Lutra.Application/Verspakketten/UpdateVerspakket.Command.cs @@ -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 /// /// Updates an existing verspakket. /// - public sealed record Command(Guid Id, string Naam, int PrijsInCenten, int AantalPersonen, Guid SupermarktId) : ICommand; + public sealed record Command( + Guid Id, + string Naam, + int PrijsInCenten, + int AantalPersonen, + Guid SupermarktId, + IReadOnlyList? Fotos = null) : ICommand; } diff --git a/Lutra/Lutra.Application/Verspakketten/UpdateVerspakket.Handler.cs b/Lutra/Lutra.Application/Verspakketten/UpdateVerspakket.Handler.cs index a6d997d..f12d1a2 100644 --- a/Lutra/Lutra.Application/Verspakketten/UpdateVerspakket.Handler.cs +++ b/Lutra/Lutra.Application/Verspakketten/UpdateVerspakket.Handler.cs @@ -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(); diff --git a/Lutra/Lutra.Domain/Entities/BaseEntity.cs b/Lutra/Lutra.Domain/Entities/BaseEntity.cs index 60a7d0d..5e52adc 100644 --- a/Lutra/Lutra.Domain/Entities/BaseEntity.cs +++ b/Lutra/Lutra.Domain/Entities/BaseEntity.cs @@ -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; } diff --git a/Lutra/Lutra.Domain/Entities/Beoordeling.cs b/Lutra/Lutra.Domain/Entities/Beoordeling.cs index c8bc51c..f4982da 100644 --- a/Lutra/Lutra.Domain/Entities/Beoordeling.cs +++ b/Lutra/Lutra.Domain/Entities/Beoordeling.cs @@ -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!; } diff --git a/Lutra/Lutra.Domain/Entities/Verspakket.cs b/Lutra/Lutra.Domain/Entities/Verspakket.cs index 66f018f..9008f67 100644 --- a/Lutra/Lutra.Domain/Entities/Verspakket.cs +++ b/Lutra/Lutra.Domain/Entities/Verspakket.cs @@ -5,6 +5,7 @@ namespace Lutra.Domain.Entities; public class Verspakket : BaseEntity { private readonly List _beoordelingen = []; + private readonly List _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 Beoordelingen => _beoordelingen.AsReadOnly(); + public IReadOnlyCollection 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; + } } \ No newline at end of file diff --git a/Lutra/Lutra.Domain/Entities/VerspakketFoto.cs b/Lutra/Lutra.Domain/Entities/VerspakketFoto.cs new file mode 100644 index 0000000..874e0ad --- /dev/null +++ b/Lutra/Lutra.Domain/Entities/VerspakketFoto.cs @@ -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!; +} diff --git a/Lutra/Lutra.Infrastructure.Migrator/Lutra.Infrastructure.Migrator.csproj b/Lutra/Lutra.Infrastructure.Migrator/Lutra.Infrastructure.Migrator.csproj index 7c181e2..fffa615 100644 --- a/Lutra/Lutra.Infrastructure.Migrator/Lutra.Infrastructure.Migrator.csproj +++ b/Lutra/Lutra.Infrastructure.Migrator/Lutra.Infrastructure.Migrator.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/Lutra/Lutra.Infrastructure/Lutra.Infrastructure.Sql.csproj b/Lutra/Lutra.Infrastructure/Lutra.Infrastructure.Sql.csproj index c1bfb82..e46a7e6 100644 --- a/Lutra/Lutra.Infrastructure/Lutra.Infrastructure.Sql.csproj +++ b/Lutra/Lutra.Infrastructure/Lutra.Infrastructure.Sql.csproj @@ -7,12 +7,16 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + + + + + diff --git a/Lutra/Lutra.Infrastructure/LutraDbContext.cs b/Lutra/Lutra.Infrastructure/LutraDbContext.cs index 403df50..f4c0bbd 100644 --- a/Lutra/Lutra.Infrastructure/LutraDbContext.cs +++ b/Lutra/Lutra.Infrastructure/LutraDbContext.cs @@ -15,22 +15,33 @@ public class LutraDbContext : DbContext, ILutraDbContext public DbSet Beoordelingen => Set(); + public DbSet VerspakketFotos => Set(); + public DbSet Verspaketten => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - modelBuilder.Entity() - .ToTable("Beoordelingen"); + // Global soft-delete filter: exclude logically deleted entities from all queries. + modelBuilder.Entity().HasQueryFilter(b => !b.IsDeleted); + modelBuilder.Entity().HasQueryFilter(f => !f.IsDeleted); + modelBuilder.Entity().HasQueryFilter(s => !s.IsDeleted); modelBuilder.Entity(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 }); } + /// + /// Populates audit fields on tracked entities before persisting. + /// public override Task SaveChangesAsync(CancellationToken cancellationToken = default) { + var now = DateTime.UtcNow; + + foreach (var entry in ChangeTracker.Entries()) + { + 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); } } diff --git a/Lutra/Lutra.Infrastructure/LutraDbContextFactory.cs b/Lutra/Lutra.Infrastructure/LutraDbContextFactory.cs index 4d1aab2..4cee1fd 100644 --- a/Lutra/Lutra.Infrastructure/LutraDbContextFactory.cs +++ b/Lutra/Lutra.Infrastructure/LutraDbContextFactory.cs @@ -1,20 +1,58 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; 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. +/// Reads ConnectionStrings:LutraDb from the API project's appsettings.json. /// internal sealed class LutraDbContextFactory : IDesignTimeDbContextFactory { + 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("", StringComparison.Ordinal)) + { + throw new InvalidOperationException($"ConnectionStrings:{ConnectionStringName} is not configured. Set it in appsettings.Development.json or via {ConnectionStringEnvironmentVariableName}."); + } + var options = new DbContextOptionsBuilder() - .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."); + } } diff --git a/Lutra/Lutra.Infrastructure/Migrations/20260425120000_AddVerspakketFotos.cs b/Lutra/Lutra.Infrastructure/Migrations/20260425120000_AddVerspakketFotos.cs new file mode 100644 index 0000000..74b85f1 --- /dev/null +++ b/Lutra/Lutra.Infrastructure/Migrations/20260425120000_AddVerspakketFotos.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Lutra.Infrastructure.Sql.Migrations +{ + /// + public partial class AddVerspakketFotos : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "VerspakketFotos", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ModifiedAt = table.Column(type: "timestamp with time zone", nullable: false), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true), + Data = table.Column(type: "bytea", nullable: false), + VerspakketId = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "VerspakketFotos"); + } + } +} diff --git a/Lutra/Lutra.Infrastructure/Migrations/20260425121000_AddIsMainImageToVerspakketFoto.Designer.cs b/Lutra/Lutra.Infrastructure/Migrations/20260425121000_AddIsMainImageToVerspakketFoto.Designer.cs new file mode 100644 index 0000000..7d71c1b --- /dev/null +++ b/Lutra/Lutra.Infrastructure/Migrations/20260425121000_AddIsMainImageToVerspakketFoto.Designer.cs @@ -0,0 +1,201 @@ +// +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 + { + /// + 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("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", 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Data") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("IsMainImage") + .HasColumnType("boolean"); + + 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("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 + } + } +} diff --git a/Lutra/Lutra.Infrastructure/Migrations/20260425121000_AddIsMainImageToVerspakketFoto.cs b/Lutra/Lutra.Infrastructure/Migrations/20260425121000_AddIsMainImageToVerspakketFoto.cs new file mode 100644 index 0000000..73dee2e --- /dev/null +++ b/Lutra/Lutra.Infrastructure/Migrations/20260425121000_AddIsMainImageToVerspakketFoto.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Lutra.Infrastructure.Sql.Migrations +{ + /// + public partial class AddIsMainImageToVerspakketFoto : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsMainImage", + table: "VerspakketFotos", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsMainImage", + table: "VerspakketFotos"); + } + } +} diff --git a/Lutra/Lutra.Infrastructure/Migrations/20260425194247_ReplaceVerspakketFotosWithSingleMainImage.Designer.cs b/Lutra/Lutra.Infrastructure/Migrations/20260425194247_ReplaceVerspakketFotosWithSingleMainImage.Designer.cs new file mode 100644 index 0000000..c1d4eb0 --- /dev/null +++ b/Lutra/Lutra.Infrastructure/Migrations/20260425194247_ReplaceVerspakketFotosWithSingleMainImage.Designer.cs @@ -0,0 +1,201 @@ +// +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 + { + /// + 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("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", 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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 + } + } +} diff --git a/Lutra/Lutra.Infrastructure/Migrations/20260425194247_ReplaceVerspakketFotosWithSingleMainImage.cs b/Lutra/Lutra.Infrastructure/Migrations/20260425194247_ReplaceVerspakketFotosWithSingleMainImage.cs new file mode 100644 index 0000000..707f7b0 --- /dev/null +++ b/Lutra/Lutra.Infrastructure/Migrations/20260425194247_ReplaceVerspakketFotosWithSingleMainImage.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Lutra.Infrastructure.Sql.Migrations +{ + /// + public partial class ReplaceVerspakketFotosWithSingleMainImage : Migration + { + /// + 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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_VerspakketFotos_VerspakketId", + table: "VerspakketFotos"); + + migrationBuilder.AddColumn( + name: "IsMainImage", + table: "VerspakketFotos", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateIndex( + name: "IX_VerspakketFotos_VerspakketId", + table: "VerspakketFotos", + column: "VerspakketId"); + } + } +} diff --git a/Lutra/Lutra.Infrastructure/Migrations/LutraDbContextModelSnapshot.cs b/Lutra/Lutra.Infrastructure/Migrations/LutraDbContextModelSnapshot.cs index 1498258..9deb858 100644 --- a/Lutra/Lutra.Infrastructure/Migrations/LutraDbContextModelSnapshot.cs +++ b/Lutra/Lutra.Infrastructure/Migrations/LutraDbContextModelSnapshot.cs @@ -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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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 } diff --git a/Lutra/README.md b/Lutra/README.md new file mode 100644 index 0000000..33f1735 --- /dev/null +++ b/Lutra/README.md @@ -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=;Username=;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.