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"; + } +}