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>
274 lines
9.1 KiB
C#
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
|
|
);
|
|
}
|