fixes and tweaks
This commit is contained in:
+1
@@ -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
|
||||
|
||||
@@ -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, 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<CreateVerspakket.Response>();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -40,7 +40,7 @@ public abstract class IntegrationTestBase : IClassFixture<LutraApiFactory>, IAsy
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -5,12 +5,8 @@ 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")]
|
||||
@@ -21,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/"]
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"LutraDb": "Host=db.m91.nl;Username=user;Password=password;Database=Lutra"
|
||||
"LutraDb": ""
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+99
@@ -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,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; }
|
||||
}
|
||||
|
||||
@@ -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,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<Response>;
|
||||
Beoordeling? Beoordeling,
|
||||
IReadOnlyList<VerspakketFoto>? Fotos = null) : ICommand<Response>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+201
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+201
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user