fixes and tweaks

This commit is contained in:
moarten
2026-04-29 20:36:08 +02:00
parent b71f45e76c
commit 385119bb27
58 changed files with 1512 additions and 350 deletions
@@ -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();