From cc09b46cfb3caddb155ddcecf2cd1a2c8687441c Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Tue, 4 Aug 2026 11:31:16 +0200 Subject: [PATCH] Voeg leesbare slug toe aan Offering naast het GUID-ID 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 --- .../Controllers/OfferingsControllerTests.cs | 4 +- .../Repositories/OfferingRepositoryTests.cs | 32 +++++++ .../Services/OfferingsServiceTests.cs | 52 +++++++++++ .../Services/SlugifierTests.cs | 24 +++++ .../Data/Entities/Offering.cs | 1 + .../Data/OfferingsDbContext.cs | 1 + ...20260804113000_AddOfferingSlug.Designer.cs | 93 +++++++++++++++++++ .../20260804113000_AddOfferingSlug.cs | 39 ++++++++ .../OfferingsDbContextModelSnapshot.cs | 5 + .../Models/OfferingAdminDto.cs | 1 + .../Models/OfferingDto.cs | 1 + .../Repositories/IOfferingRepository.cs | 1 + .../Repositories/OfferingRepository.cs | 3 + .../Services/OfferingsService.cs | 21 +++++ .../Services/Slugifier.cs | 40 ++++++++ 15 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 src/SlpModularCms.Modules.Offerings.Tests/Services/SlugifierTests.cs create mode 100644 src/SlpModularCms.Modules.Offerings/Migrations/20260804113000_AddOfferingSlug.Designer.cs create mode 100644 src/SlpModularCms.Modules.Offerings/Migrations/20260804113000_AddOfferingSlug.cs create mode 100644 src/SlpModularCms.Modules.Offerings/Services/Slugifier.cs diff --git a/src/SlpModularCms.Modules.Offerings.Tests/Controllers/OfferingsControllerTests.cs b/src/SlpModularCms.Modules.Offerings.Tests/Controllers/OfferingsControllerTests.cs index ba4f5de..bac6d9f 100644 --- a/src/SlpModularCms.Modules.Offerings.Tests/Controllers/OfferingsControllerTests.cs +++ b/src/SlpModularCms.Modules.Offerings.Tests/Controllers/OfferingsControllerTests.cs @@ -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"); diff --git a/src/SlpModularCms.Modules.Offerings.Tests/Repositories/OfferingRepositoryTests.cs b/src/SlpModularCms.Modules.Offerings.Tests/Repositories/OfferingRepositoryTests.cs index c332748..6661169 100644 --- a/src/SlpModularCms.Modules.Offerings.Tests/Repositories/OfferingRepositoryTests.cs +++ b/src/SlpModularCms.Modules.Offerings.Tests/Repositories/OfferingRepositoryTests.cs @@ -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", diff --git a/src/SlpModularCms.Modules.Offerings.Tests/Services/OfferingsServiceTests.cs b/src/SlpModularCms.Modules.Offerings.Tests/Services/OfferingsServiceTests.cs index de235c8..91635c2 100644 --- a/src/SlpModularCms.Modules.Offerings.Tests/Services/OfferingsServiceTests.cs +++ b/src/SlpModularCms.Modules.Offerings.Tests/Services/OfferingsServiceTests.cs @@ -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(), Arg.Any()); + } + // --- UpdateAsync --- [Fact] diff --git a/src/SlpModularCms.Modules.Offerings.Tests/Services/SlugifierTests.cs b/src/SlpModularCms.Modules.Offerings.Tests/Services/SlugifierTests.cs new file mode 100644 index 0000000..a417db5 --- /dev/null +++ b/src/SlpModularCms.Modules.Offerings.Tests/Services/SlugifierTests.cs @@ -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"); + } +} diff --git a/src/SlpModularCms.Modules.Offerings/Data/Entities/Offering.cs b/src/SlpModularCms.Modules.Offerings/Data/Entities/Offering.cs index 1878503..0825584 100644 --- a/src/SlpModularCms.Modules.Offerings/Data/Entities/Offering.cs +++ b/src/SlpModularCms.Modules.Offerings/Data/Entities/Offering.cs @@ -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; diff --git a/src/SlpModularCms.Modules.Offerings/Data/OfferingsDbContext.cs b/src/SlpModularCms.Modules.Offerings/Data/OfferingsDbContext.cs index edca50d..84444d0 100644 --- a/src/SlpModularCms.Modules.Offerings/Data/OfferingsDbContext.cs +++ b/src/SlpModularCms.Modules.Offerings/Data/OfferingsDbContext.cs @@ -17,6 +17,7 @@ public class OfferingsDbContext(DbContextOptions 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); diff --git a/src/SlpModularCms.Modules.Offerings/Migrations/20260804113000_AddOfferingSlug.Designer.cs b/src/SlpModularCms.Modules.Offerings/Migrations/20260804113000_AddOfferingSlug.Designer.cs new file mode 100644 index 0000000..d1a6b65 --- /dev/null +++ b/src/SlpModularCms.Modules.Offerings/Migrations/20260804113000_AddOfferingSlug.Designer.cs @@ -0,0 +1,93 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime"); + + b.Property("CtaLabel") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetime"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("Featured") + .HasColumnType("tinyint(1)"); + + b.Property("Features") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsDeleted") + .HasColumnType("tinyint(1)"); + + b.Property("LastModifiedByUserId") + .HasColumnType("char(36)"); + + b.Property("Price") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PriceNote") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(130) + .HasColumnType("varchar(130)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime"); + + b.HasKey("Id"); + + b.ToTable("OfferingsOfferings", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/SlpModularCms.Modules.Offerings/Migrations/20260804113000_AddOfferingSlug.cs b/src/SlpModularCms.Modules.Offerings/Migrations/20260804113000_AddOfferingSlug.cs new file mode 100644 index 0000000..846dd65 --- /dev/null +++ b/src/SlpModularCms.Modules.Offerings/Migrations/20260804113000_AddOfferingSlug.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SlpModularCms.Modules.Offerings.Migrations +{ + /// + public partial class AddOfferingSlug : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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 = '';"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Slug", + table: "OfferingsOfferings"); + } + } +} diff --git a/src/SlpModularCms.Modules.Offerings/Migrations/OfferingsDbContextModelSnapshot.cs b/src/SlpModularCms.Modules.Offerings/Migrations/OfferingsDbContextModelSnapshot.cs index 04b3d30..9484838 100644 --- a/src/SlpModularCms.Modules.Offerings/Migrations/OfferingsDbContextModelSnapshot.cs +++ b/src/SlpModularCms.Modules.Offerings/Migrations/OfferingsDbContextModelSnapshot.cs @@ -67,6 +67,11 @@ namespace SlpModularCms.Modules.Offerings.Migrations .HasMaxLength(100) .HasColumnType("varchar(100)"); + b.Property("Slug") + .IsRequired() + .HasMaxLength(130) + .HasColumnType("varchar(130)"); + b.Property("Title") .IsRequired() .HasMaxLength(100) diff --git a/src/SlpModularCms.Modules.Offerings/Models/OfferingAdminDto.cs b/src/SlpModularCms.Modules.Offerings/Models/OfferingAdminDto.cs index 8640696..dc8bc67 100644 --- a/src/SlpModularCms.Modules.Offerings/Models/OfferingAdminDto.cs +++ b/src/SlpModularCms.Modules.Offerings/Models/OfferingAdminDto.cs @@ -5,6 +5,7 @@ namespace SlpModularCms.Modules.Offerings.Models; [ExcludeFromCodeCoverage] public record OfferingAdminDto( string Id, + string Slug, string Title, string Description, string Price, diff --git a/src/SlpModularCms.Modules.Offerings/Models/OfferingDto.cs b/src/SlpModularCms.Modules.Offerings/Models/OfferingDto.cs index 4613b56..ac3a640 100644 --- a/src/SlpModularCms.Modules.Offerings/Models/OfferingDto.cs +++ b/src/SlpModularCms.Modules.Offerings/Models/OfferingDto.cs @@ -5,6 +5,7 @@ namespace SlpModularCms.Modules.Offerings.Models; [ExcludeFromCodeCoverage] public record OfferingDto( string Id, + string Slug, string Title, string Description, string Price, diff --git a/src/SlpModularCms.Modules.Offerings/Repositories/IOfferingRepository.cs b/src/SlpModularCms.Modules.Offerings/Repositories/IOfferingRepository.cs index e392caf..1b636cb 100644 --- a/src/SlpModularCms.Modules.Offerings/Repositories/IOfferingRepository.cs +++ b/src/SlpModularCms.Modules.Offerings/Repositories/IOfferingRepository.cs @@ -7,6 +7,7 @@ public interface IOfferingRepository { Task> GetAllAsync(); Task GetByIdAsync(Guid id); + Task ExistsBySlugAsync(string slug, Guid? excludeId = null); Task AddAsync(Offering offering); void Update(Offering offering); Task GetMaxDisplayOrderAsync(); diff --git a/src/SlpModularCms.Modules.Offerings/Repositories/OfferingRepository.cs b/src/SlpModularCms.Modules.Offerings/Repositories/OfferingRepository.cs index df58598..7796c2f 100644 --- a/src/SlpModularCms.Modules.Offerings/Repositories/OfferingRepository.cs +++ b/src/SlpModularCms.Modules.Offerings/Repositories/OfferingRepository.cs @@ -13,6 +13,9 @@ public class OfferingRepository(OfferingsDbContext context) : IOfferingRepositor public async Task GetByIdAsync(Guid id) => await context.Offerings.FirstOrDefaultAsync(o => o.Id == id); + public async Task 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); diff --git a/src/SlpModularCms.Modules.Offerings/Services/OfferingsService.cs b/src/SlpModularCms.Modules.Offerings/Services/OfferingsService.cs index 18920b6..0c77fcd 100644 --- a/src/SlpModularCms.Modules.Offerings/Services/OfferingsService.cs +++ b/src/SlpModularCms.Modules.Offerings/Services/OfferingsService.cs @@ -25,6 +25,7 @@ public class OfferingsService(IOfferingRepository repository, ILogger 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 new( o.Id.ToString(), + o.Slug, o.Title, o.Description, o.Price, diff --git a/src/SlpModularCms.Modules.Offerings/Services/Slugifier.cs b/src/SlpModularCms.Modules.Offerings/Services/Slugifier.cs new file mode 100644 index 0000000..0e1c039 --- /dev/null +++ b/src/SlpModularCms.Modules.Offerings/Services/Slugifier.cs @@ -0,0 +1,40 @@ +using System.Text; + +namespace SlpModularCms.Modules.Offerings.Services; + +/// +/// Turns a title into a URL/display-safe slug (lowercase, hyphen-separated, no diacritics). +/// Pure string transform — uniqueness is handled by the caller. +/// +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"; + } +}