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
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:
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user