Voeg leesbare slug toe aan Offering naast het GUID-ID
Continuous Integration / config (pull_request) Successful in 12s
Continuous Integration / changes (pull_request) Successful in 21s
Continuous Integration / frontend-prepare (pull_request) Skipped
Continuous Integration / frontend-build (pull_request) Skipped
Continuous Integration / frontend-test (pull_request) Skipped
Continuous Integration / frontend-lint (pull_request) Skipped
Continuous Integration / backend-build (pull_request) Successful in 4m31s
Continuous Integration / vulnerability-scan (pull_request) Successful in 3m57s
Continuous Integration / backend-test (pull_request) Successful in 6m11s
Continuous Integration / publish-production (pull_request) Skipped
Continuous Integration / deploy-production (pull_request) Skipped
Continuous Integration / publish-test (pull_request) Successful in 5m51s
Continuous Integration / deploy-test (pull_request) Skipped

Het Offering-ID is een GUID en niet geschikt om aan gebruikers te tonen.
Voeg een op de titel gebaseerde, unieke slug toe die via de public en
admin API wordt meegegeven, zodat de frontend iets leesbaars kan tonen
in plaats van het ruwe ID. Het ID blijft ongewijzigd de echte identifier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 11:31:16 +02:00
co-authored by Claude Sonnet 5
parent 1ba945fa45
commit cc09b46cfb
15 changed files with 316 additions and 2 deletions
@@ -24,10 +24,10 @@ public class OfferingsControllerTests
}
private static OfferingDto PublicDto() => new(
Guid.NewGuid().ToString(), "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false);
Guid.NewGuid().ToString(), "title", "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false);
private static OfferingAdminDto AdminDto(int displayOrder = 0) => new(
Guid.NewGuid().ToString(), "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false, displayOrder);
Guid.NewGuid().ToString(), "title", "Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact", false, displayOrder);
private static CreateOfferingRequest CreateRequest() => new(
"Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact");
@@ -152,6 +152,37 @@ public class OfferingRepositoryTests : IDisposable
result.Should().BeNull();
}
[Fact]
public async Task ExistsBySlugAsync_ReturnsTrue_WhenSlugTaken()
{
await _sut.AddAsync(Offering("Website", displayOrder: 0));
await _sut.SaveChangesAsync();
var result = await _sut.ExistsBySlugAsync("website");
result.Should().BeTrue();
}
[Fact]
public async Task ExistsBySlugAsync_ReturnsFalse_WhenSlugFree()
{
var result = await _sut.ExistsBySlugAsync("unused-slug");
result.Should().BeFalse();
}
[Fact]
public async Task ExistsBySlugAsync_ExcludesGivenId()
{
var offering = Offering("Website", displayOrder: 0);
await _sut.AddAsync(offering);
await _sut.SaveChangesAsync();
var result = await _sut.ExistsBySlugAsync("website", excludeId: offering.Id);
result.Should().BeFalse();
}
[Fact]
public async Task Update_PersistsChanges()
{
@@ -170,6 +201,7 @@ public class OfferingRepositoryTests : IDisposable
private static Offering Offering(string title, int displayOrder) => new()
{
Id = Guid.NewGuid(),
Slug = title.ToLowerInvariant(),
Title = title,
Description = "Description",
Price = "€ 100",
@@ -27,6 +27,7 @@ public class OfferingsServiceTests
private static Offering ExistingOffering(bool featured = false, int displayOrder = 0) => new()
{
Id = Guid.NewGuid(),
Slug = "existing",
Title = "Existing",
Description = "Description",
Price = "€ 100",
@@ -91,6 +92,57 @@ public class OfferingsServiceTests
await _repo.DidNotReceive().BeginTransactionAsync();
}
// --- Slug generation ---
[Fact]
public async Task CreateAsync_GeneratesSlugFromTitle()
{
_repo.GetMaxDisplayOrderAsync().Returns(-1);
var dto = await CreateSut().CreateAsync(CreateRequest(), Guid.NewGuid());
dto.Slug.Should().Be("title");
}
[Fact]
public async Task CreateAsync_AppendsSuffix_WhenSlugAlreadyTaken()
{
_repo.GetMaxDisplayOrderAsync().Returns(-1);
_repo.ExistsBySlugAsync("title", null).Returns(true);
_repo.ExistsBySlugAsync("title-2", null).Returns(true);
_repo.ExistsBySlugAsync("title-3", null).Returns(false);
var dto = await CreateSut().CreateAsync(CreateRequest(), Guid.NewGuid());
dto.Slug.Should().Be("title-3");
}
[Fact]
public async Task UpdateAsync_RegeneratesSlug_WhenTitleChanges()
{
var target = ExistingOffering();
_repo.GetByIdAsync(target.Id).Returns(target);
_repo.ExistsBySlugAsync("new-title", target.Id).Returns(false);
var request = new UpdateOfferingRequest("New Title", "Description", "€ 100", "per month", ["Feature 1"], "Contact");
var dto = await CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
dto!.Slug.Should().Be("new-title");
}
[Fact]
public async Task UpdateAsync_KeepsExistingSlug_WhenTitleUnchanged()
{
var target = ExistingOffering();
_repo.GetByIdAsync(target.Id).Returns(target);
var request = new UpdateOfferingRequest(target.Title, "Description", "€ 100", "per month", ["Feature 1"], "Contact");
var dto = await CreateSut().UpdateAsync(target.Id, request, Guid.NewGuid());
dto!.Slug.Should().Be("existing");
await _repo.DidNotReceive().ExistsBySlugAsync(Arg.Any<string>(), Arg.Any<Guid?>());
}
// --- UpdateAsync ---
[Fact]
@@ -0,0 +1,24 @@
using FluentAssertions;
using SlpModularCms.Modules.Offerings.Services;
namespace SlpModularCms.Modules.Offerings.Tests.Services;
public class SlugifierTests
{
[Theory]
[InlineData("Landingspagina", "landingspagina")]
[InlineData("Website", "website")]
[InlineData("Grote Website & Maatwerk", "grote-website-maatwerk")]
[InlineData(" Trimmed Title ", "trimmed-title")]
[InlineData("Café Ontwerp", "cafe-ontwerp")]
public void Slugify_ProducesReadableSlug(string title, string expected)
{
Slugifier.Slugify(title).Should().Be(expected);
}
[Fact]
public void Slugify_FallsBackToPakket_WhenTitleHasNoAlphanumerics()
{
Slugifier.Slugify("---").Should().Be("pakket");
}
}
@@ -3,6 +3,7 @@ namespace SlpModularCms.Modules.Offerings.Data.Entities;
public class Offering
{
public Guid Id { get; set; }
public string Slug { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Price { get; set; } = string.Empty;
@@ -17,6 +17,7 @@ public class OfferingsDbContext(DbContextOptions<OfferingsDbContext> options) :
{
entity.ToTable("OfferingsOfferings");
entity.HasKey(e => e.Id);
entity.Property(e => e.Slug).IsRequired().HasMaxLength(130);
entity.Property(e => e.Title).IsRequired().HasMaxLength(100);
entity.Property(e => e.Description).IsRequired().HasMaxLength(500);
entity.Property(e => e.Price).IsRequired().HasMaxLength(50);
@@ -0,0 +1,93 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using SlpModularCms.Modules.Offerings.Data;
#nullable disable
namespace SlpModularCms.Modules.Offerings.Migrations
{
[DbContext(typeof(OfferingsDbContext))]
[Migration("20260804113000_AddOfferingSlug")]
partial class AddOfferingSlug
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("SlpModularCms.Modules.Offerings.Data.Entities.Offering", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetime");
b.Property<string>("CtaLabel")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetime");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("DisplayOrder")
.HasColumnType("int");
b.Property<bool>("Featured")
.HasColumnType("tinyint(1)");
b.Property<string>("Features")
.IsRequired()
.HasColumnType("longtext");
b.Property<bool>("IsDeleted")
.HasColumnType("tinyint(1)");
b.Property<Guid>("LastModifiedByUserId")
.HasColumnType("char(36)");
b.Property<string>("Price")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("PriceNote")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(130)
.HasColumnType("varchar(130)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetime");
b.HasKey("Id");
b.ToTable("OfferingsOfferings", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SlpModularCms.Modules.Offerings.Migrations
{
/// <inheritdoc />
public partial class AddOfferingSlug : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Slug",
table: "OfferingsOfferings",
type: "varchar(130)",
maxLength: 130,
nullable: false,
defaultValue: "");
// Backfill existing rows with a title-derived slug so the NOT NULL column
// never holds an empty string for pre-existing data. New/edited rows get
// their slug from OfferingsService.GenerateUniqueSlugAsync instead, which
// also handles collisions this best-effort SQL backfill doesn't.
migrationBuilder.Sql(
"UPDATE OfferingsOfferings " +
"SET Slug = LOWER(TRIM(BOTH '-' FROM REGEXP_REPLACE(TRIM(Title), '[^a-zA-Z0-9]+', '-'))) " +
"WHERE Slug = '';");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Slug",
table: "OfferingsOfferings");
}
}
}
@@ -67,6 +67,11 @@ namespace SlpModularCms.Modules.Offerings.Migrations
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(130)
.HasColumnType("varchar(130)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
@@ -5,6 +5,7 @@ namespace SlpModularCms.Modules.Offerings.Models;
[ExcludeFromCodeCoverage]
public record OfferingAdminDto(
string Id,
string Slug,
string Title,
string Description,
string Price,
@@ -5,6 +5,7 @@ namespace SlpModularCms.Modules.Offerings.Models;
[ExcludeFromCodeCoverage]
public record OfferingDto(
string Id,
string Slug,
string Title,
string Description,
string Price,
@@ -7,6 +7,7 @@ public interface IOfferingRepository
{
Task<IReadOnlyList<Offering>> GetAllAsync();
Task<Offering?> GetByIdAsync(Guid id);
Task<bool> ExistsBySlugAsync(string slug, Guid? excludeId = null);
Task AddAsync(Offering offering);
void Update(Offering offering);
Task<int> GetMaxDisplayOrderAsync();
@@ -13,6 +13,9 @@ public class OfferingRepository(OfferingsDbContext context) : IOfferingRepositor
public async Task<Offering?> GetByIdAsync(Guid id)
=> await context.Offerings.FirstOrDefaultAsync(o => o.Id == id);
public async Task<bool> ExistsBySlugAsync(string slug, Guid? excludeId = null)
=> await context.Offerings.AnyAsync(o => o.Slug == slug && o.Id != (excludeId ?? Guid.Empty));
public async Task AddAsync(Offering offering)
=> await context.Offerings.AddAsync(offering);
@@ -25,6 +25,7 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
var offering = new Offering
{
Id = Guid.NewGuid(),
Slug = await GenerateUniqueSlugAsync(request.Title),
Title = request.Title,
Description = request.Description,
Price = request.Price,
@@ -69,6 +70,9 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
if (offering is null)
return null;
if (!string.Equals(offering.Title, request.Title, StringComparison.Ordinal))
offering.Slug = await GenerateUniqueSlugAsync(request.Title, excludeId: offering.Id);
offering.Title = request.Title;
offering.Description = request.Description;
offering.Price = request.Price;
@@ -202,8 +206,24 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
repository.Update(current);
}
private async Task<string> GenerateUniqueSlugAsync(string title, Guid? excludeId = null)
{
var baseSlug = Slugifier.Slugify(title);
var slug = baseSlug;
var suffix = 2;
while (await repository.ExistsBySlugAsync(slug, excludeId))
{
slug = $"{baseSlug}-{suffix}";
suffix++;
}
return slug;
}
private static OfferingDto ToPublicDto(Offering o) => new(
o.Id.ToString(),
o.Slug,
o.Title,
o.Description,
o.Price,
@@ -215,6 +235,7 @@ public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsS
private static OfferingAdminDto ToAdminDto(Offering o) => new(
o.Id.ToString(),
o.Slug,
o.Title,
o.Description,
o.Price,
@@ -0,0 +1,40 @@
using System.Text;
namespace SlpModularCms.Modules.Offerings.Services;
/// <summary>
/// Turns a title into a URL/display-safe slug (lowercase, hyphen-separated, no diacritics).
/// Pure string transform — uniqueness is handled by the caller.
/// </summary>
public static class Slugifier
{
public static string Slugify(string value)
{
var normalized = value.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(normalized.Length);
var lastWasHyphen = false;
foreach (var c in normalized)
{
var category = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c);
if (category == System.Globalization.UnicodeCategory.NonSpacingMark)
continue;
if (char.IsLetterOrDigit(c))
{
builder.Append(char.ToLowerInvariant(c));
lastWasHyphen = false;
}
else if (!lastWasHyphen && builder.Length > 0)
{
builder.Append('-');
lastWasHyphen = true;
}
}
if (lastWasHyphen)
builder.Length--;
return builder.Length > 0 ? builder.ToString() : "pakket";
}
}