Files
slp-modular-cms/src/SlpModularCms.Modules.Offerings/Services/OfferingsService.cs
T
SluijsensandClaude Sonnet 5 1b974696bf Maak slug in CMS zichtbaar en inline aanpasbaar
De CMS-admin toonde het nieuwe Slug-veld nog niet — dat bestond alleen
in de API-respons. Voegt een read-only slugweergave onder het
titelveld toe met een potlood-icoon om 'm inline te bewerken.

Backend: Create/UpdateOfferingRequest accepteren nu een optionele
Slug-override. Zonder expliciete waarde blijft het bestaande gedrag
(auto-genereren uit titel, regenereren bij titelwijziging). Met een
expliciete waarde wordt die genormaliseerd en op uniekheid
gecontroleerd; een botsing geeft 409 Conflict (RFC 9457 ProblemDetails,
naar het patroon van MasterControlledAvailabilityException).

Frontend: de slug wordt alleen meegestuurd als de admin 'm daadwerkelijk
bewerkt heeft (dirtyFields.slug), zodat een ongemoeide slug bij een
titelwijziging gewoon blijft auto-regenereren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 15:31:10 +02:00

274 lines
9.1 KiB
C#

using Microsoft.Extensions.Logging;
using SlpModularCms.Modules.Offerings.Data.Entities;
using SlpModularCms.Modules.Offerings.Models;
using SlpModularCms.Modules.Offerings.Repositories;
namespace SlpModularCms.Modules.Offerings.Services;
public class OfferingsService(IOfferingRepository repository, ILogger<OfferingsService> logger) : IOfferingsService
{
public async Task<IReadOnlyList<OfferingDto>> GetPublicOfferingsAsync()
{
var offerings = await repository.GetAllAsync();
return offerings.Select(ToPublicDto).ToList();
}
public async Task<IReadOnlyList<OfferingAdminDto>> GetAllForAdminAsync()
{
var offerings = await repository.GetAllAsync();
return offerings.Select(ToAdminDto).ToList();
}
public async Task<OfferingAdminDto> CreateAsync(CreateOfferingRequest request, Guid callerId)
{
var now = DateTimeOffset.UtcNow;
var offering = new Offering
{
Id = Guid.NewGuid(),
Slug = await ResolveSlugAsync(request.Slug, request.Title),
Title = request.Title,
Description = request.Description,
Price = request.Price,
PriceNote = request.PriceNote,
Features = request.Features,
CtaLabel = request.CtaLabel,
Featured = request.Featured,
DisplayOrder = await repository.GetMaxDisplayOrderAsync() + 1,
CreatedAt = now,
UpdatedAt = now,
LastModifiedByUserId = callerId,
};
// BR-OFF-01 (featured exclusivity) touches two rows in one logical action —
// transactional per NFR Design Pattern 1 so a mid-operation failure can never
// leave two offerings both featured or the new one unsaved after the old one
// was already un-featured.
if (request.Featured)
{
await using var transaction = await repository.BeginTransactionAsync();
await UnfeatureCurrentAsync();
await repository.AddAsync(offering);
await repository.SaveChangesAsync();
await transaction.CommitAsync();
}
else
{
await repository.AddAsync(offering);
await repository.SaveChangesAsync();
}
logger.LogInformation(
"Offering {OfferingId} {Action} by user {LastModifiedByUserId}",
offering.Id, "Created", callerId);
return ToAdminDto(offering);
}
public async Task<OfferingAdminDto?> UpdateAsync(Guid id, UpdateOfferingRequest request, Guid callerId)
{
var offering = await repository.GetByIdAsync(id);
if (offering is null)
return null;
if (!string.IsNullOrWhiteSpace(request.Slug))
{
offering.Slug = await ResolveExplicitSlugAsync(request.Slug, excludeId: offering.Id);
}
else 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;
offering.PriceNote = request.PriceNote;
offering.Features = request.Features;
offering.CtaLabel = request.CtaLabel;
offering.Featured = request.Featured;
offering.UpdatedAt = DateTimeOffset.UtcNow;
offering.LastModifiedByUserId = callerId;
if (request.Featured)
{
await using var transaction = await repository.BeginTransactionAsync();
await UnfeatureCurrentAsync(exceptId: offering.Id);
repository.Update(offering);
await repository.SaveChangesAsync();
await transaction.CommitAsync();
}
else
{
repository.Update(offering);
await repository.SaveChangesAsync();
}
logger.LogInformation(
"Offering {OfferingId} {Action} by user {LastModifiedByUserId}",
offering.Id, "Updated", callerId);
return ToAdminDto(offering);
}
public async Task<bool> DeleteAsync(Guid id, Guid callerId)
{
var offering = await repository.GetByIdAsync(id);
if (offering is null)
return false;
// BR-OFF-02: soft delete is never blocked, including for the last remaining offering.
offering.IsDeleted = true;
offering.DeletedAt = DateTimeOffset.UtcNow;
offering.UpdatedAt = offering.DeletedAt.Value;
offering.LastModifiedByUserId = callerId;
repository.Update(offering);
await repository.SaveChangesAsync();
logger.LogInformation(
"Offering {OfferingId} {Action} by user {LastModifiedByUserId}",
offering.Id, "Deleted", callerId);
return true;
}
public async Task ReorderAsync(IReadOnlyList<Guid> orderedIds, Guid callerId)
{
await using var transaction = await repository.BeginTransactionAsync();
var now = DateTimeOffset.UtcNow;
for (var i = 0; i < orderedIds.Count; i++)
{
var offering = await repository.GetByIdAsync(orderedIds[i]);
if (offering is null)
continue;
offering.DisplayOrder = i;
offering.UpdatedAt = now;
offering.LastModifiedByUserId = callerId;
repository.Update(offering);
}
await repository.SaveChangesAsync();
await transaction.CommitAsync();
logger.LogInformation(
"Offerings reordered ({Count} items) by user {LastModifiedByUserId}",
orderedIds.Count, callerId);
}
public async Task MoveUpAsync(Guid id, Guid callerId)
{
var offering = await repository.GetByIdAsync(id);
if (offering is null)
return;
var previous = await repository.GetPreviousAsync(offering.DisplayOrder);
if (previous is null)
return; // BR-OFF-03: already first, no-op
await using var transaction = await repository.BeginTransactionAsync();
SwapDisplayOrder(offering, previous, callerId);
await repository.SaveChangesAsync();
await transaction.CommitAsync();
}
public async Task MoveDownAsync(Guid id, Guid callerId)
{
var offering = await repository.GetByIdAsync(id);
if (offering is null)
return;
var next = await repository.GetNextAsync(offering.DisplayOrder);
if (next is null)
return; // BR-OFF-03: already last, no-op
await using var transaction = await repository.BeginTransactionAsync();
SwapDisplayOrder(offering, next, callerId);
await repository.SaveChangesAsync();
await transaction.CommitAsync();
}
private void SwapDisplayOrder(Offering a, Offering b, Guid callerId)
{
(a.DisplayOrder, b.DisplayOrder) = (b.DisplayOrder, a.DisplayOrder);
var now = DateTimeOffset.UtcNow;
a.UpdatedAt = now;
b.UpdatedAt = now;
a.LastModifiedByUserId = callerId;
b.LastModifiedByUserId = callerId;
repository.Update(a);
repository.Update(b);
}
private async Task UnfeatureCurrentAsync(Guid? exceptId = null)
{
var current = await repository.GetFeaturedAsync();
if (current is null || current.Id == exceptId)
return;
current.Featured = false;
current.UpdatedAt = DateTimeOffset.UtcNow;
repository.Update(current);
}
private async Task<string> ResolveSlugAsync(string? explicitSlug, string title)
=> !string.IsNullOrWhiteSpace(explicitSlug)
? await ResolveExplicitSlugAsync(explicitSlug, excludeId: null)
: await GenerateUniqueSlugAsync(title);
/// <summary>
/// Normalizes an admin-supplied slug and enforces uniqueness. Unlike auto-generation,
/// a taken slug is rejected outright (OfferingSlugConflictException) rather than
/// silently suffixed — the admin picked this value on purpose.
/// </summary>
private async Task<string> ResolveExplicitSlugAsync(string explicitSlug, Guid? excludeId)
{
var slug = Slugifier.Slugify(explicitSlug);
if (await repository.ExistsBySlugAsync(slug, excludeId))
throw new OfferingSlugConflictException(slug);
return slug;
}
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,
o.PriceNote,
o.Features,
o.CtaLabel,
o.Featured
);
private static OfferingAdminDto ToAdminDto(Offering o) => new(
o.Id.ToString(),
o.Slug,
o.Title,
o.Description,
o.Price,
o.PriceNote,
o.Features,
o.CtaLabel,
o.Featured,
o.DisplayOrder
);
}