Adds the Offerings module and retargets the CI/CD pipeline to Api.SlpSoftware
Continuous Integration / config (pull_request) Successful in 12s
Continuous Integration / changes (pull_request) Successful in 22s
Continuous Integration / backend-build (pull_request) Successful in 5m53s
Continuous Integration / vulnerability-scan (pull_request) Successful in 5m46s
Continuous Integration / frontend-prepare (pull_request) Successful in 1m54s
Continuous Integration / backend-test (pull_request) Successful in 7m37s
Continuous Integration / frontend-build (pull_request) Successful in 2m14s
Continuous Integration / frontend-test (pull_request) Successful in 4m59s
Continuous Integration / frontend-lint (pull_request) Successful in 2m2s
Continuous Integration / publish-production (pull_request) Skipped
Continuous Integration / deploy-production (pull_request) Skipped
Continuous Integration / publish-test (pull_request) Successful in 7m34s
Continuous Integration / deploy-test (pull_request) Skipped

Implements Unit 2 "Offerings" (backend module, admin CRUD UI with
drag-and-drop reordering, public GET /api/v1/offerings endpoint) and
executes the feature's D-15 CI/CD cutover, switching the deploy
pipeline's build/publish target from SlpModularCms.Api to
SlpModularCms.Api.SlpSoftware.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWyStNL2ZsjrS7FLd7xvvN
This commit is contained in:
2026-08-02 16:23:09 +02:00
co-authored by Claude Sonnet 5
parent b6e9c07c06
commit cfb06b28b6
79 changed files with 4069 additions and 94 deletions
@@ -0,0 +1,227 @@
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(),
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;
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 static OfferingDto ToPublicDto(Offering o) => new(
o.Id.ToString(),
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.Title,
o.Description,
o.Price,
o.PriceNote,
o.Features,
o.CtaLabel,
o.Featured,
o.DisplayOrder
);
}