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 logger) : IOfferingsService { public async Task> GetPublicOfferingsAsync() { var offerings = await repository.GetAllAsync(); return offerings.Select(ToPublicDto).ToList(); } public async Task> GetAllForAdminAsync() { var offerings = await repository.GetAllAsync(); return offerings.Select(ToAdminDto).ToList(); } public async Task 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 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 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 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 ResolveSlugAsync(string? explicitSlug, string title) => !string.IsNullOrWhiteSpace(explicitSlug) ? await ResolveExplicitSlugAsync(explicitSlug, excludeId: null) : await GenerateUniqueSlugAsync(title); /// /// 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. /// private async Task 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 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 ); }