backend phase 5: service catalog & nurse pricing variants
Two-tier service model the marketplace is priced and searched on. Admin catalog skeleton (categories + EAV option groups/values, addable as data not migrations; NULL category = cross-category) and the nurse pricing layer (nurse_service_variants — the atomic bookable unit: category + one value per required dimension at the nurse's own IRR price and price unit). - New `catalog` schema via one additive migration; Price BIGINT (no floats), on the wire as a string of digits; total = price + unit + session_count. - Duplicate-listing guard: deterministic option_set_hash + filtered UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL + friendly 409 pre-check. One value per dimension; required groups (incl. cross-category) enforced; deactivate, never delete. - Public catalog browse cached behind a CatalogCache generation token, invalidated on any admin write. IVariantSnapshotSerializer shipped for b8. - Contract (catalog.md) + handoff + report published; swagger refreshed. 122 tests green; zero new build warnings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Baya.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic hash of a variant's answered option-set — the key behind the duplicate-listing filtered
|
||||
/// unique index (<c>UNIQUE(nurse_id, service_category_id, option_set_hash)</c>). Because the option-set is
|
||||
/// multi-row, a plain composite unique index can't express "same set of choices"; hashing the sorted
|
||||
/// <c>(group_id, value_id)</c> pairs reduces it to one comparable column. Sorting makes the hash order-
|
||||
/// independent; the same set of choices always yields the same 64-char hex hash. An empty option-set
|
||||
/// (a category with no answered groups) hashes to a stable value, so two such variants still collide.
|
||||
/// </summary>
|
||||
public static class OptionSetHash
|
||||
{
|
||||
public static string Compute(IEnumerable<(long GroupId, long ValueId)> pairs)
|
||||
{
|
||||
var canonical = string.Join(
|
||||
"|",
|
||||
pairs.OrderBy(p => p.GroupId).ThenBy(p => p.ValueId)
|
||||
.Select(p => $"{p.GroupId}:{p.ValueId}"));
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Unicode;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Models.Catalog;
|
||||
|
||||
namespace Baya.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical implementation of <see cref="IVariantSnapshotSerializer"/>. Emits a stable, camelCase JSON
|
||||
/// object carrying the category id + labels, each resolved <c>(group label, value label)</c>, the price
|
||||
/// (as a string of digits), price unit, session count, display name, and the variant id. Property order is
|
||||
/// fixed (declaration order) so the output is deterministic for a given input.
|
||||
/// </summary>
|
||||
public sealed class VariantSnapshotSerializer : IVariantSnapshotSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
// Persian labels must land in the snapshot as readable text, not \uXXXX escapes; the encoder still
|
||||
// escapes the HTML-sensitive ASCII characters so the stored JSON stays safe to embed.
|
||||
Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public string Serialize(VariantSnapshot snapshot)
|
||||
{
|
||||
// Money crosses the snapshot as a string of IRR-Rial digits (integer money, no floats). Invariant
|
||||
// culture so the digits are always ASCII regardless of the ambient locale.
|
||||
var payload = new
|
||||
{
|
||||
variantId = snapshot.VariantId,
|
||||
serviceCategoryId = snapshot.ServiceCategoryId,
|
||||
categoryNameFa = snapshot.CategoryNameFa,
|
||||
categoryNameEn = snapshot.CategoryNameEn,
|
||||
price = snapshot.Price.ToString(CultureInfo.InvariantCulture),
|
||||
priceUnit = snapshot.PriceUnit,
|
||||
sessionCount = snapshot.SessionCount,
|
||||
displayName = snapshot.DisplayName,
|
||||
options = snapshot.Options.Select(o => new
|
||||
{
|
||||
o.OptionGroupId,
|
||||
o.GroupNameFa,
|
||||
o.GroupNameEn,
|
||||
o.OptionValueId,
|
||||
o.ValueNameFa,
|
||||
o.ValueNameEn
|
||||
})
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(payload, Options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Baya.Application.Models.Catalog;
|
||||
|
||||
namespace Baya.Application.Contracts.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Emits the canonical <c>variant_snapshot_json</c> that Booking (backend-phase-8) freezes onto a
|
||||
/// <c>booking_requests</c> row so later variant edits/deactivation never mutate past bookings, disputes, or
|
||||
/// invoices. A pure function — no I/O, no state. This phase ships and unit-tests it; b8 persists its output.
|
||||
/// Not an external-service seam: it has a single real implementation.
|
||||
/// </summary>
|
||||
public interface IVariantSnapshotSerializer
|
||||
{
|
||||
/// <summary>Serialize a variant + its resolved options as they are at serialize time. Price is emitted
|
||||
/// as a string of IRR-Rial digits (integer money, no floats).</summary>
|
||||
string Serialize(VariantSnapshot snapshot);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// The admin catalog skeleton (categories → option groups → option values) plus the public browse reads.
|
||||
/// Reads are projected + no-tracking (callers cache them); admin getters are tracked (include inactive,
|
||||
/// exclude soft-deleted) for edit/toggle. The applicable-groups read returns a category's own groups
|
||||
/// <b>plus</b> every cross-category (NULL-category) group — the load-bearing EAV rule.
|
||||
/// </summary>
|
||||
public interface ICatalogRepository
|
||||
{
|
||||
/// <summary>All active categories, ordered by sort order — cached by the caller.</summary>
|
||||
Task<IReadOnlyList<ServiceCategoryDto>> ListActiveCategoriesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The category's own active groups plus every cross-category (NULL) active group, each with
|
||||
/// its active values, ordered by sort order. Empty is valid (no dimensions defined yet).</summary>
|
||||
Task<IReadOnlyList<OptionGroupDto>> GetApplicableGroupsAsync(long categoryId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Active-only category projection for variant creation — null if missing or deactivated.</summary>
|
||||
Task<ServiceCategoryDto?> GetActiveCategoryAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>A single group projected with its active values — the honest response for a group mutation
|
||||
/// (a freshly created group returns an empty value list). Null if the group is absent/soft-deleted.</summary>
|
||||
Task<OptionGroupDto?> GetGroupDtoAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
// Admin: tracked lookups (include inactive, exclude soft-deleted) for edit/toggle.
|
||||
Task<ServiceCategory?> GetCategoryAsync(long id, CancellationToken cancellationToken);
|
||||
Task<ServiceOptionGroup?> GetGroupAsync(long id, CancellationToken cancellationToken);
|
||||
Task<ServiceOptionValue?> GetValueAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Whether a non-soft-deleted category exists (any active state) — parent check for a group.</summary>
|
||||
Task<bool> CategoryExistsAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Whether a non-soft-deleted group exists — parent check for a value.</summary>
|
||||
Task<bool> GroupExistsAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
Task AddCategoryAsync(ServiceCategory category, CancellationToken cancellationToken);
|
||||
Task AddGroupAsync(ServiceOptionGroup group, CancellationToken cancellationToken);
|
||||
Task AddValueAsync(ServiceOptionValue value, CancellationToken cancellationToken);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// A nurse's priced offerings — the atomic bookable unit. Writes go through the owning-nurse tenancy check;
|
||||
/// reads project to <see cref="VariantDto"/> with resolved category/option labels. The duplicate-listing
|
||||
/// guard is a pre-check here plus the filtered unique index backstop in the EF configuration.
|
||||
/// </summary>
|
||||
public interface INurseServiceVariantRepository
|
||||
{
|
||||
Task AddAsync(NurseServiceVariant variant, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked, tenancy-scoped getter for owner scalar edits/toggle. Null if not owned/absent —
|
||||
/// existence of another nurse's variant is never leaked.</summary>
|
||||
Task<NurseServiceVariant?> GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Duplicate-listing pre-check: a non-deleted variant with this exact option-set already exists
|
||||
/// for the nurse+category. <paramref name="excludeVariantId"/> skips the variant being edited.</summary>
|
||||
Task<bool> DuplicateHashExistsAsync(long nurseId, long serviceCategoryId, string optionSetHash, long? excludeVariantId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The nurse's own offerings (active + inactive), paginated, active-first, resolved labels.</summary>
|
||||
Task<PagedResult<VariantDto>> ListMineAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Full projection for the owning nurse — any status. Null if not owned/absent.</summary>
|
||||
Task<VariantDto?> GetOwnedProjectedAsync(long id, long nurseId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Full projection for admin — any status. Null if absent.</summary>
|
||||
Task<VariantDto?> GetProjectedAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Public-safe projection: an <b>active</b> variant only. Null when missing/inactive.</summary>
|
||||
Task<VariantDto?> GetPublicProjectedAsync(long id, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ public interface IUnitOfWork
|
||||
public IGeoRepository GeoRepository { get; }
|
||||
public INurseServiceAreaRepository NurseServiceAreaRepository { get; }
|
||||
public ICustomerAddressRepository CustomerAddressRepository { get; }
|
||||
public ICatalogRepository CatalogRepository { get; }
|
||||
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
|
||||
Task CommitAsync();
|
||||
ValueTask RollBackAsync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
|
||||
namespace Baya.Application.Features.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Cache-key scheme for the read-heavy public catalog lookups (categories + a category's option groups).
|
||||
/// Every data key is namespaced by a generation token; any admin catalog mutation bumps the token, which
|
||||
/// orphans all prior catalog entries in one move (they lapse by TTL). Mirrors the geo generation-token
|
||||
/// scheme so cascade invalidation stays trivial and correct — deactivating a category or editing a group
|
||||
/// instantly refreshes the whole namespace without enumerating child keys.
|
||||
/// </summary>
|
||||
internal static class CatalogCache
|
||||
{
|
||||
private const string VersionKey = "catalog:version";
|
||||
|
||||
// Catalog reference data changes rarely; a modest TTL bounds staleness even if a bump is ever missed.
|
||||
public static readonly TimeSpan Ttl = TimeSpan.FromHours(1);
|
||||
|
||||
public static ValueTask<string> VersionAsync(ICacheService cache, CancellationToken cancellationToken)
|
||||
=> cache.GetOrCreateAsync(VersionKey, _ => ValueTask.FromResult(NewToken()), null, cancellationToken);
|
||||
|
||||
public static ValueTask InvalidateAsync(ICacheService cache, CancellationToken cancellationToken)
|
||||
=> cache.SetAsync(VersionKey, NewToken(), null, cancellationToken);
|
||||
|
||||
public static string CategoriesKey(string version) => $"catalog:{version}:categories";
|
||||
|
||||
public static string OptionGroupsKey(string version, long categoryId) => $"catalog:{version}:groups:{categoryId}";
|
||||
|
||||
private static string NewToken() => Guid.NewGuid().ToString("N");
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.CreateServiceCategory;
|
||||
|
||||
internal sealed class CreateServiceCategoryCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<CreateServiceCategoryCommand, OperationResult<ServiceCategoryDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<ServiceCategoryDto>> Handle(CreateServiceCategoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var category = new ServiceCategory
|
||||
{
|
||||
NameFa = request.NameFa,
|
||||
NameEn = request.NameEn,
|
||||
DescriptionFa = request.DescriptionFa,
|
||||
DescriptionEn = request.DescriptionEn,
|
||||
IconKey = request.IconKey,
|
||||
SortOrder = request.SortOrder,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await unitOfWork.CatalogRepository.AddCategoryAsync(category, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
await CatalogCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<ServiceCategoryDto>.SuccessResult(new ServiceCategoryDto(
|
||||
category.Id, category.NameFa, category.NameEn, category.DescriptionFa, category.DescriptionEn,
|
||||
category.IconKey, category.SortOrder, category.IsActive));
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.CreateServiceCategory;
|
||||
|
||||
public sealed class CreateServiceCategoryCommandValidator : AbstractValidator<CreateServiceCategoryCommand>
|
||||
{
|
||||
public CreateServiceCategoryCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.DescriptionFa).MaximumLength(1000);
|
||||
RuleFor(x => x.DescriptionEn).MaximumLength(1000);
|
||||
RuleFor(x => x.IconKey).MaximumLength(100);
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.CreateServiceCategory;
|
||||
|
||||
/// <summary>Admin: add a top-level care category (data, not code). Both labels required. Invalidates cache.</summary>
|
||||
public record CreateServiceCategoryCommand(
|
||||
string NameFa,
|
||||
string NameEn,
|
||||
string? DescriptionFa,
|
||||
string? DescriptionEn,
|
||||
string? IconKey,
|
||||
int SortOrder) : IRequest<OperationResult<ServiceCategoryDto>>;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.CreateServiceOptionGroup;
|
||||
|
||||
internal sealed class CreateServiceOptionGroupCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<CreateServiceOptionGroupCommand, OperationResult<OptionGroupDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<OptionGroupDto>> Handle(CreateServiceOptionGroupCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ServiceCategoryId is { } categoryId
|
||||
&& !await unitOfWork.CatalogRepository.CategoryExistsAsync(categoryId, cancellationToken))
|
||||
return OperationResult<OptionGroupDto>.FailureResult(nameof(request.ServiceCategoryId), "Category not found.");
|
||||
|
||||
var group = new ServiceOptionGroup
|
||||
{
|
||||
ServiceCategoryId = request.ServiceCategoryId,
|
||||
NameFa = request.NameFa,
|
||||
NameEn = request.NameEn,
|
||||
IsRequired = request.IsRequired,
|
||||
SortOrder = request.SortOrder,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await unitOfWork.CatalogRepository.AddGroupAsync(group, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
await CatalogCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
var dto = await unitOfWork.CatalogRepository.GetGroupDtoAsync(group.Id, cancellationToken);
|
||||
return OperationResult<OptionGroupDto>.SuccessResult(dto!);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.CreateServiceOptionGroup;
|
||||
|
||||
public sealed class CreateServiceOptionGroupCommandValidator : AbstractValidator<CreateServiceOptionGroupCommand>
|
||||
{
|
||||
public CreateServiceOptionGroupCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
// Null is the deliberate cross-category case; only a supplied id must be positive.
|
||||
RuleFor(x => x.ServiceCategoryId).GreaterThan(0).When(x => x.ServiceCategoryId.HasValue);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.CreateServiceOptionGroup;
|
||||
|
||||
/// <summary>
|
||||
/// Admin: add a pricing dimension. <see cref="ServiceCategoryId"/> == <c>null</c> makes it
|
||||
/// <b>cross-category</b> (applies to every category). Invalidates the catalog cache.
|
||||
/// </summary>
|
||||
public record CreateServiceOptionGroupCommand(
|
||||
long? ServiceCategoryId,
|
||||
string NameFa,
|
||||
string NameEn,
|
||||
bool IsRequired,
|
||||
int SortOrder) : IRequest<OperationResult<OptionGroupDto>>;
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.CreateServiceOptionValue;
|
||||
|
||||
internal sealed class CreateServiceOptionValueCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<CreateServiceOptionValueCommand, OperationResult<OptionValueDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<OptionValueDto>> Handle(CreateServiceOptionValueCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await unitOfWork.CatalogRepository.GroupExistsAsync(request.OptionGroupId, cancellationToken))
|
||||
return OperationResult<OptionValueDto>.FailureResult(nameof(request.OptionGroupId), "Option group not found.");
|
||||
|
||||
var value = new ServiceOptionValue
|
||||
{
|
||||
OptionGroupId = request.OptionGroupId,
|
||||
NameFa = request.NameFa,
|
||||
NameEn = request.NameEn,
|
||||
SortOrder = request.SortOrder,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await unitOfWork.CatalogRepository.AddValueAsync(value, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
await CatalogCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<OptionValueDto>.SuccessResult(new OptionValueDto(
|
||||
value.Id, value.NameFa, value.NameEn, value.SortOrder, value.IsActive));
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.CreateServiceOptionValue;
|
||||
|
||||
public sealed class CreateServiceOptionValueCommandValidator : AbstractValidator<CreateServiceOptionValueCommand>
|
||||
{
|
||||
public CreateServiceOptionValueCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.OptionGroupId).GreaterThan(0);
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.CreateServiceOptionValue;
|
||||
|
||||
/// <summary>Admin: add a concrete choice to an option group. Both labels required. Invalidates cache.</summary>
|
||||
public record CreateServiceOptionValueCommand(
|
||||
long OptionGroupId,
|
||||
string NameFa,
|
||||
string NameEn,
|
||||
int SortOrder) : IRequest<OperationResult<OptionValueDto>>;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.SetServiceCategoryActive;
|
||||
|
||||
internal sealed class SetServiceCategoryActiveCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<SetServiceCategoryActiveCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(SetServiceCategoryActiveCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var category = await unitOfWork.CatalogRepository.GetCategoryAsync(request.Id, cancellationToken);
|
||||
if (category is null)
|
||||
return OperationResult<bool>.NotFoundResult("Category not found.");
|
||||
|
||||
category.IsActive = request.IsActive;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
await CatalogCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.SetServiceCategoryActive;
|
||||
|
||||
/// <summary>
|
||||
/// Admin: toggle a category's active flag. <b>Soft state only — never hard-delete.</b> Deactivating hides
|
||||
/// the category from public browse and from new variant creation; existing variants in it are left intact
|
||||
/// (their bookings/history survive via the booking snapshot). <see cref="Id"/> comes from the route.
|
||||
/// </summary>
|
||||
public record SetServiceCategoryActiveCommand(long Id, bool IsActive) : IRequest<OperationResult<bool>>;
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.UpdateServiceCategory;
|
||||
|
||||
internal sealed class UpdateServiceCategoryCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<UpdateServiceCategoryCommand, OperationResult<ServiceCategoryDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<ServiceCategoryDto>> Handle(UpdateServiceCategoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var category = await unitOfWork.CatalogRepository.GetCategoryAsync(request.Id, cancellationToken);
|
||||
if (category is null)
|
||||
return OperationResult<ServiceCategoryDto>.NotFoundResult("Category not found.");
|
||||
|
||||
category.NameFa = request.NameFa;
|
||||
category.NameEn = request.NameEn;
|
||||
category.DescriptionFa = request.DescriptionFa;
|
||||
category.DescriptionEn = request.DescriptionEn;
|
||||
category.IconKey = request.IconKey;
|
||||
category.SortOrder = request.SortOrder;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
await CatalogCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<ServiceCategoryDto>.SuccessResult(new ServiceCategoryDto(
|
||||
category.Id, category.NameFa, category.NameEn, category.DescriptionFa, category.DescriptionEn,
|
||||
category.IconKey, category.SortOrder, category.IsActive));
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.UpdateServiceCategory;
|
||||
|
||||
public sealed class UpdateServiceCategoryCommandValidator : AbstractValidator<UpdateServiceCategoryCommand>
|
||||
{
|
||||
// Id is supplied by the route (set after model binding), so it is not validated here.
|
||||
public UpdateServiceCategoryCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.DescriptionFa).MaximumLength(1000);
|
||||
RuleFor(x => x.DescriptionEn).MaximumLength(1000);
|
||||
RuleFor(x => x.IconKey).MaximumLength(100);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.UpdateServiceCategory;
|
||||
|
||||
/// <summary>Admin: edit a category's labels/description/icon/order. <see cref="Id"/> comes from the route.</summary>
|
||||
public record UpdateServiceCategoryCommand(
|
||||
long Id,
|
||||
string NameFa,
|
||||
string NameEn,
|
||||
string? DescriptionFa,
|
||||
string? DescriptionEn,
|
||||
string? IconKey,
|
||||
int SortOrder) : IRequest<OperationResult<ServiceCategoryDto>>;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.UpdateServiceOptionGroup;
|
||||
|
||||
internal sealed class UpdateServiceOptionGroupCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<UpdateServiceOptionGroupCommand, OperationResult<OptionGroupDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<OptionGroupDto>> Handle(UpdateServiceOptionGroupCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var group = await unitOfWork.CatalogRepository.GetGroupAsync(request.Id, cancellationToken);
|
||||
if (group is null)
|
||||
return OperationResult<OptionGroupDto>.NotFoundResult("Option group not found.");
|
||||
|
||||
if (request.ServiceCategoryId is { } categoryId
|
||||
&& !await unitOfWork.CatalogRepository.CategoryExistsAsync(categoryId, cancellationToken))
|
||||
return OperationResult<OptionGroupDto>.FailureResult(nameof(request.ServiceCategoryId), "Category not found.");
|
||||
|
||||
group.ServiceCategoryId = request.ServiceCategoryId;
|
||||
group.NameFa = request.NameFa;
|
||||
group.NameEn = request.NameEn;
|
||||
group.IsRequired = request.IsRequired;
|
||||
group.SortOrder = request.SortOrder;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
await CatalogCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
var dto = await unitOfWork.CatalogRepository.GetGroupDtoAsync(group.Id, cancellationToken);
|
||||
return OperationResult<OptionGroupDto>.SuccessResult(dto!);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.UpdateServiceOptionGroup;
|
||||
|
||||
public sealed class UpdateServiceOptionGroupCommandValidator : AbstractValidator<UpdateServiceOptionGroupCommand>
|
||||
{
|
||||
// Id is supplied by the route (set after model binding), so it is not validated here.
|
||||
public UpdateServiceOptionGroupCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.ServiceCategoryId).GreaterThan(0).When(x => x.ServiceCategoryId.HasValue);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.UpdateServiceOptionGroup;
|
||||
|
||||
/// <summary>Admin: edit a dimension's category scope (null = cross-category), labels, required flag, order.
|
||||
/// <see cref="Id"/> comes from the route.</summary>
|
||||
public record UpdateServiceOptionGroupCommand(
|
||||
long Id,
|
||||
long? ServiceCategoryId,
|
||||
string NameFa,
|
||||
string NameEn,
|
||||
bool IsRequired,
|
||||
int SortOrder) : IRequest<OperationResult<OptionGroupDto>>;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.UpdateServiceOptionValue;
|
||||
|
||||
internal sealed class UpdateServiceOptionValueCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<UpdateServiceOptionValueCommand, OperationResult<OptionValueDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<OptionValueDto>> Handle(UpdateServiceOptionValueCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var value = await unitOfWork.CatalogRepository.GetValueAsync(request.Id, cancellationToken);
|
||||
if (value is null)
|
||||
return OperationResult<OptionValueDto>.NotFoundResult("Option value not found.");
|
||||
|
||||
value.NameFa = request.NameFa;
|
||||
value.NameEn = request.NameEn;
|
||||
value.SortOrder = request.SortOrder;
|
||||
value.IsActive = request.IsActive;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
await CatalogCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<OptionValueDto>.SuccessResult(new OptionValueDto(
|
||||
value.Id, value.NameFa, value.NameEn, value.SortOrder, value.IsActive));
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.UpdateServiceOptionValue;
|
||||
|
||||
public sealed class UpdateServiceOptionValueCommandValidator : AbstractValidator<UpdateServiceOptionValueCommand>
|
||||
{
|
||||
// Id is supplied by the route (set after model binding), so it is not validated here.
|
||||
public UpdateServiceOptionValueCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Commands.UpdateServiceOptionValue;
|
||||
|
||||
/// <summary>
|
||||
/// Admin: edit a value's labels/order and activate/deactivate it. Re-parenting to a different group is
|
||||
/// deliberately not allowed — it would silently change the meaning of variants that already answered with
|
||||
/// this value. <see cref="Id"/> comes from the route.
|
||||
/// </summary>
|
||||
public record UpdateServiceOptionValueCommand(
|
||||
long Id,
|
||||
string NameFa,
|
||||
string NameEn,
|
||||
int SortOrder,
|
||||
bool IsActive) : IRequest<OperationResult<OptionValueDto>>;
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Queries.GetCatalogCategories;
|
||||
|
||||
internal sealed class GetCatalogCategoriesQueryHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<GetCatalogCategoriesQuery, OperationResult<PagedResult<ServiceCategoryDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<ServiceCategoryDto>>> Handle(GetCatalogCategoriesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
|
||||
|
||||
var version = await CatalogCache.VersionAsync(cache, cancellationToken);
|
||||
|
||||
// The active-category set is small, near-static reference data — cache the whole ordered list once
|
||||
// per generation token and page it in memory, so a mutation's cache bump refreshes every page.
|
||||
var all = await cache.GetOrCreateAsync(
|
||||
CatalogCache.CategoriesKey(version),
|
||||
async ct => await unitOfWork.CatalogRepository.ListActiveCategoriesAsync(ct),
|
||||
CatalogCache.Ttl,
|
||||
cancellationToken);
|
||||
|
||||
var items = all.Skip((page - 1) * pageSize).Take(pageSize).ToList();
|
||||
|
||||
return OperationResult<PagedResult<ServiceCategoryDto>>.SuccessResult(
|
||||
new PagedResult<ServiceCategoryDto>(items, all.Count, page, pageSize));
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Queries.GetCatalogCategories;
|
||||
|
||||
/// <summary>Public: active categories ordered by sort order, paginated. Cached reference data.</summary>
|
||||
public record GetCatalogCategoriesQuery(int Page = 1, int PageSize = 50)
|
||||
: IRequest<OperationResult<PagedResult<ServiceCategoryDto>>>;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Queries.GetCategoryOptionGroups;
|
||||
|
||||
internal sealed class GetCategoryOptionGroupsQueryHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<GetCategoryOptionGroupsQuery, OperationResult<IReadOnlyList<OptionGroupDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<IReadOnlyList<OptionGroupDto>>> Handle(GetCategoryOptionGroupsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var version = await CatalogCache.VersionAsync(cache, cancellationToken);
|
||||
|
||||
var groups = await cache.GetOrCreateAsync(
|
||||
CatalogCache.OptionGroupsKey(version, request.CategoryId),
|
||||
async ct => await unitOfWork.CatalogRepository.GetApplicableGroupsAsync(request.CategoryId, ct),
|
||||
CatalogCache.Ttl,
|
||||
cancellationToken);
|
||||
|
||||
return OperationResult<IReadOnlyList<OptionGroupDto>>.SuccessResult(groups);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Queries.GetCategoryOptionGroups;
|
||||
|
||||
/// <summary>
|
||||
/// Public: a category's <b>applicable</b> option groups — its own groups plus every cross-category (NULL)
|
||||
/// group — each with its active values and <c>is_required</c>, ordered by sort order. The skeleton the
|
||||
/// nurse builder fills in and the customer browses. Cached reference data.
|
||||
/// </summary>
|
||||
public record GetCategoryOptionGroupsQuery(long CategoryId)
|
||||
: IRequest<OperationResult<IReadOnlyList<OptionGroupDto>>>;
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Variants.Commands.CreateVariant;
|
||||
|
||||
internal sealed class CreateVariantCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<CreateVariantCommand, OperationResult<VariantDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<VariantDto>> Handle(CreateVariantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<VariantDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<VariantDto>.ForbiddenResult("Only a nurse can create a variant.");
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<VariantDto>.FailureResult("No nurse profile exists yet. Create your profile first.");
|
||||
|
||||
// Catalog must be seeded and the category active — a variant can't rest on a missing/inactive category.
|
||||
var category = await unitOfWork.CatalogRepository.GetActiveCategoryAsync(request.ServiceCategoryId, cancellationToken);
|
||||
if (category is null)
|
||||
return OperationResult<VariantDto>.FailureResult(nameof(request.ServiceCategoryId), "Category not found or inactive.");
|
||||
|
||||
// Applicable = the category's own active groups PLUS every cross-category (NULL) active group.
|
||||
var applicableGroups = await unitOfWork.CatalogRepository.GetApplicableGroupsAsync(request.ServiceCategoryId, cancellationToken);
|
||||
var groupById = applicableGroups.ToDictionary(g => g.Id);
|
||||
|
||||
// Every submitted (group, value) must apply to this category and the value must belong to its group.
|
||||
foreach (var selection in request.Options)
|
||||
{
|
||||
if (!groupById.TryGetValue(selection.OptionGroupId, out var group))
|
||||
return OperationResult<VariantDto>.FailureResult(
|
||||
nameof(request.Options), $"Option group {selection.OptionGroupId} does not apply to this category.");
|
||||
|
||||
if (group.Values.All(v => v.Id != selection.OptionValueId))
|
||||
return OperationResult<VariantDto>.FailureResult(
|
||||
nameof(request.Options), $"Option value {selection.OptionValueId} is not a valid choice for '{group.NameFa}'.");
|
||||
}
|
||||
|
||||
// One value per dimension: no group answered twice.
|
||||
var answeredGroupIds = request.Options.Select(o => o.OptionGroupId).ToList();
|
||||
if (answeredGroupIds.Count != answeredGroupIds.Distinct().Count())
|
||||
return OperationResult<VariantDto>.FailureResult(nameof(request.Options), "A dimension was answered more than once.");
|
||||
|
||||
// Every required dimension (incl. cross-category ones) must be answered.
|
||||
var answered = answeredGroupIds.ToHashSet();
|
||||
var missingRequired = applicableGroups.Where(g => g.IsRequired && !answered.Contains(g.Id)).ToList();
|
||||
if (missingRequired.Count > 0)
|
||||
return OperationResult<VariantDto>.FailureResult(
|
||||
nameof(request.Options),
|
||||
$"Required dimension(s) not answered: {string.Join(", ", missingRequired.Select(g => g.NameFa))}.");
|
||||
|
||||
var resolvedOptions = ResolveOptions(request.Options, groupById);
|
||||
var optionSetHash = OptionSetHash.Compute(request.Options.Select(o => (o.OptionGroupId, o.OptionValueId)));
|
||||
|
||||
// Duplicate-listing guard: friendly pre-check ahead of the filtered unique-index DB backstop.
|
||||
if (await unitOfWork.NurseServiceVariantRepository.DuplicateHashExistsAsync(nid, category.Id, optionSetHash, null, cancellationToken))
|
||||
return OperationResult<VariantDto>.ConflictResult("You already offer this exact configuration in this category.");
|
||||
|
||||
var price = long.Parse(request.Price, NumberStyles.None, CultureInfo.InvariantCulture);
|
||||
var displayName = string.IsNullOrWhiteSpace(request.DisplayName)
|
||||
? BuildDisplayName(category.NameFa, resolvedOptions)
|
||||
: request.DisplayName.Trim();
|
||||
|
||||
var variant = new NurseServiceVariant
|
||||
{
|
||||
NurseId = nid,
|
||||
ServiceCategoryId = category.Id,
|
||||
Price = price,
|
||||
PriceUnit = request.PriceUnit,
|
||||
SessionCount = request.SessionCount,
|
||||
DisplayName = displayName,
|
||||
OptionSetHash = optionSetHash,
|
||||
IsActive = true,
|
||||
Options = request.Options
|
||||
.Select(o => new NurseServiceVariantOption
|
||||
{
|
||||
OptionGroupId = o.OptionGroupId,
|
||||
OptionValueId = o.OptionValueId
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
|
||||
// DEFERRED (b7): this is the write that later fans a variant out into nurse_search_index. Keep it the
|
||||
// single trigger point — do not build the index here.
|
||||
await unitOfWork.NurseServiceVariantRepository.AddAsync(variant, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<VariantDto>.SuccessResult(new VariantDto(
|
||||
variant.Id,
|
||||
category.Id,
|
||||
category.NameFa,
|
||||
category.NameEn,
|
||||
variant.Price.ToString(CultureInfo.InvariantCulture),
|
||||
variant.PriceUnit,
|
||||
variant.SessionCount,
|
||||
variant.DisplayName,
|
||||
variant.IsActive,
|
||||
resolvedOptions));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<VariantOptionDto> ResolveOptions(
|
||||
IReadOnlyList<VariantOptionSelection> selections,
|
||||
IReadOnlyDictionary<long, OptionGroupDto> groupById)
|
||||
=> selections
|
||||
.Select(s =>
|
||||
{
|
||||
var group = groupById[s.OptionGroupId];
|
||||
var value = group.Values.First(v => v.Id == s.OptionValueId);
|
||||
return (group, value);
|
||||
})
|
||||
.OrderBy(x => x.group.SortOrder)
|
||||
.ThenBy(x => x.group.Id)
|
||||
.Select(x => new VariantOptionDto(
|
||||
x.group.Id, x.group.NameFa, x.group.NameEn, x.value.Id, x.value.NameFa, x.value.NameEn))
|
||||
.ToList();
|
||||
|
||||
private static string BuildDisplayName(string categoryNameFa, IReadOnlyList<VariantOptionDto> options)
|
||||
=> options.Count == 0
|
||||
? categoryNameFa
|
||||
: $"{categoryNameFa} · {string.Join(" · ", options.Select(o => o.ValueNameFa))}";
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
using System.Globalization;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Variants.Commands.CreateVariant;
|
||||
|
||||
public sealed class CreateVariantCommandValidator : AbstractValidator<CreateVariantCommand>
|
||||
{
|
||||
public CreateVariantCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ServiceCategoryId).GreaterThan(0);
|
||||
|
||||
// Money is a string of IRR-Rial digits — positive integer, no sign/decimal/whitespace, no overflow.
|
||||
RuleFor(x => x.Price)
|
||||
.NotEmpty()
|
||||
.Must(BePositiveIrrAmount)
|
||||
.WithMessage("Price must be a positive integer number of IRR Rials (digits only).");
|
||||
|
||||
RuleFor(x => x.PriceUnit)
|
||||
.Must(PriceUnits.IsValid)
|
||||
.WithMessage("price_unit must be one of: per_hour, per_session, per_half_day, per_day, per_24h.");
|
||||
|
||||
RuleFor(x => x.SessionCount).GreaterThan(0).When(x => x.SessionCount.HasValue);
|
||||
|
||||
RuleFor(x => x.DisplayName).MaximumLength(300);
|
||||
|
||||
// The required-group / one-value-per-dimension / value-belongs-to-group rules need the catalog, so
|
||||
// they live in the handler (clean OperationResult). Here we only enforce the shape.
|
||||
RuleFor(x => x.Options).NotNull();
|
||||
RuleForEach(x => x.Options).ChildRules(o =>
|
||||
{
|
||||
o.RuleFor(s => s.OptionGroupId).GreaterThan(0);
|
||||
o.RuleFor(s => s.OptionValueId).GreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
private static bool BePositiveIrrAmount(string value)
|
||||
=> long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var amount) && amount > 0;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Variants.Commands.CreateVariant;
|
||||
|
||||
/// <summary>
|
||||
/// The signed-in nurse builds a priced offering: a category, one chosen value per answered dimension, a
|
||||
/// price (IRR-Rial digit string) + price unit + optional session count, and an optional display-name
|
||||
/// override (auto-generated from the option labels when omitted). The nurse is derived from the caller,
|
||||
/// never the body. A duplicate identical listing returns 409; a missing required dimension returns 400.
|
||||
/// </summary>
|
||||
public record CreateVariantCommand(
|
||||
long ServiceCategoryId,
|
||||
IReadOnlyList<VariantOptionSelection> Options,
|
||||
string Price,
|
||||
string PriceUnit,
|
||||
int? SessionCount,
|
||||
string? DisplayName) : IRequest<OperationResult<VariantDto>>;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Variants.Commands.SetVariantActive;
|
||||
|
||||
internal sealed class SetVariantActiveCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<SetVariantActiveCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(SetVariantActiveCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<bool>.ForbiddenResult("Only a nurse can manage variants.");
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<bool>.NotFoundResult("Variant not found.");
|
||||
|
||||
var variant = await unitOfWork.NurseServiceVariantRepository.GetOwnedAsync(request.Id, nid, cancellationToken);
|
||||
if (variant is null)
|
||||
return OperationResult<bool>.NotFoundResult("Variant not found.");
|
||||
|
||||
variant.IsActive = request.IsActive;
|
||||
|
||||
// DEFERRED (b7): toggling active is the trigger point for the search-index add/remove.
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Variants.Commands.SetVariantActive;
|
||||
|
||||
/// <summary>
|
||||
/// The owning nurse activates/deactivates a variant. <b>Deactivate, never hard-delete.</b> A deactivated
|
||||
/// variant cannot be booked and (via b7) drops out of the search index; its past bookings/snapshots are
|
||||
/// untouched. <see cref="Id"/> comes from the route.
|
||||
/// </summary>
|
||||
public record SetVariantActiveCommand(long Id, bool IsActive) : IRequest<OperationResult<bool>>;
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Variants.Commands.UpdateVariant;
|
||||
|
||||
internal sealed class UpdateVariantCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<UpdateVariantCommand, OperationResult<VariantDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<VariantDto>> Handle(UpdateVariantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<VariantDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<VariantDto>.ForbiddenResult("Only a nurse can edit a variant.");
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<VariantDto>.NotFoundResult("Variant not found.");
|
||||
|
||||
// Tenancy: a non-owned/absent id resolves to null → not-found (existence is not leaked).
|
||||
var variant = await unitOfWork.NurseServiceVariantRepository.GetOwnedAsync(request.Id, nid, cancellationToken);
|
||||
if (variant is null)
|
||||
return OperationResult<VariantDto>.NotFoundResult("Variant not found.");
|
||||
|
||||
variant.Price = long.Parse(request.Price, NumberStyles.None, CultureInfo.InvariantCulture);
|
||||
variant.PriceUnit = request.PriceUnit;
|
||||
variant.SessionCount = request.SessionCount;
|
||||
if (!string.IsNullOrWhiteSpace(request.DisplayName))
|
||||
variant.DisplayName = request.DisplayName.Trim();
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
// Re-project with resolved labels for the response (the option-set is unchanged).
|
||||
var dto = await unitOfWork.NurseServiceVariantRepository.GetOwnedProjectedAsync(request.Id, nid, cancellationToken);
|
||||
return dto is null
|
||||
? OperationResult<VariantDto>.NotFoundResult("Variant not found.")
|
||||
: OperationResult<VariantDto>.SuccessResult(dto);
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using System.Globalization;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Variants.Commands.UpdateVariant;
|
||||
|
||||
public sealed class UpdateVariantCommandValidator : AbstractValidator<UpdateVariantCommand>
|
||||
{
|
||||
// Id is supplied by the route (set after model binding), so it is not validated here.
|
||||
public UpdateVariantCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Price)
|
||||
.NotEmpty()
|
||||
.Must(BePositiveIrrAmount)
|
||||
.WithMessage("Price must be a positive integer number of IRR Rials (digits only).");
|
||||
|
||||
RuleFor(x => x.PriceUnit)
|
||||
.Must(PriceUnits.IsValid)
|
||||
.WithMessage("price_unit must be one of: per_hour, per_session, per_half_day, per_day, per_24h.");
|
||||
|
||||
RuleFor(x => x.SessionCount).GreaterThan(0).When(x => x.SessionCount.HasValue);
|
||||
|
||||
RuleFor(x => x.DisplayName).MaximumLength(300);
|
||||
}
|
||||
|
||||
private static bool BePositiveIrrAmount(string value)
|
||||
=> long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var amount) && amount > 0;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Variants.Commands.UpdateVariant;
|
||||
|
||||
/// <summary>
|
||||
/// The owning nurse edits a variant's price, price unit, session count, and display name. The <b>option-set
|
||||
/// is immutable</b> here — changing dimensions is modelled as create-new + deactivate-old so historical
|
||||
/// meaning stays stable, which also means an edit can never collide with the duplicate-listing guard.
|
||||
/// A blank <see cref="DisplayName"/> leaves the current one unchanged. <see cref="Id"/> comes from the route.
|
||||
/// </summary>
|
||||
public record UpdateVariantCommand(
|
||||
long Id,
|
||||
string Price,
|
||||
string PriceUnit,
|
||||
int? SessionCount,
|
||||
string? DisplayName) : IRequest<OperationResult<VariantDto>>;
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Variants.Queries.GetVariant;
|
||||
|
||||
internal sealed class GetVariantQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetVariantQuery, OperationResult<VariantDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<VariantDto>> Handle(GetVariantQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var roles = currentUser.Roles;
|
||||
|
||||
// Admin: the full view of any variant, any state.
|
||||
if (roles?.Contains(RoleNames.Admin) == true)
|
||||
return Resolve(await unitOfWork.NurseServiceVariantRepository.GetProjectedAsync(request.Id, cancellationToken));
|
||||
|
||||
// Owning nurse: the full view of their own variant, any state.
|
||||
if (currentUser.UserId is { } userId && roles?.Contains(RoleNames.Nurse) == true)
|
||||
{
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is { } nid)
|
||||
{
|
||||
var owned = await unitOfWork.NurseServiceVariantRepository.GetOwnedProjectedAsync(request.Id, nid, cancellationToken);
|
||||
if (owned is not null)
|
||||
return OperationResult<VariantDto>.SuccessResult(owned);
|
||||
}
|
||||
// Not their variant → fall through to the public (active-only) projection.
|
||||
}
|
||||
|
||||
// Everyone else (incl. anonymous): the public-safe projection — active variants only.
|
||||
return Resolve(await unitOfWork.NurseServiceVariantRepository.GetPublicProjectedAsync(request.Id, cancellationToken));
|
||||
}
|
||||
|
||||
private static OperationResult<VariantDto> Resolve(VariantDto? dto)
|
||||
=> dto is null
|
||||
? OperationResult<VariantDto>.NotFoundResult("Variant not found.")
|
||||
: OperationResult<VariantDto>.SuccessResult(dto);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Variants.Queries.GetVariant;
|
||||
|
||||
/// <summary>
|
||||
/// A single variant with its full resolved option-set. The owning nurse and an admin see it in any state;
|
||||
/// any other caller (the public nurse-profile view) sees only an <b>active</b> variant. Absent/inaccessible
|
||||
/// resolves to not-found.
|
||||
/// </summary>
|
||||
public record GetVariantQuery(long Id) : IRequest<OperationResult<VariantDto>>;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Variants.Queries.ListMyVariants;
|
||||
|
||||
internal sealed class ListMyVariantsQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<ListMyVariantsQuery, OperationResult<PagedResult<VariantDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<VariantDto>>> Handle(ListMyVariantsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<PagedResult<VariantDto>>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<PagedResult<VariantDto>>.ForbiddenResult("Only a nurse can view their variants.");
|
||||
|
||||
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<PagedResult<VariantDto>>.SuccessResult(
|
||||
new PagedResult<VariantDto>([], 0, page, pageSize));
|
||||
|
||||
var result = await unitOfWork.NurseServiceVariantRepository.ListMineAsync(nid, page, pageSize, cancellationToken);
|
||||
return OperationResult<PagedResult<VariantDto>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Variants.Queries.ListMyVariants;
|
||||
|
||||
/// <summary>The signed-in nurse's own offerings — active and inactive — paginated, active-first.</summary>
|
||||
public record ListMyVariantsQuery(int Page = 1, int PageSize = 50)
|
||||
: IRequest<OperationResult<PagedResult<VariantDto>>>;
|
||||
@@ -0,0 +1,17 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// A pricing dimension applicable to a category, with its active values. <c>ServiceCategoryId == null</c>
|
||||
/// marks a cross-category group (applies to every category). This is the skeleton the nurse builder fills
|
||||
/// in and the customer browses.
|
||||
/// </summary>
|
||||
public record OptionGroupDto(
|
||||
long Id,
|
||||
long? ServiceCategoryId,
|
||||
string NameFa,
|
||||
string NameEn,
|
||||
bool IsRequired,
|
||||
int SortOrder,
|
||||
bool IsActive,
|
||||
IReadOnlyList<OptionValueDto> Values);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Baya.Application.Models.Catalog;
|
||||
|
||||
/// <summary>A concrete choice within an option group (e.g. شبانهروزی / live-in).</summary>
|
||||
public record OptionValueDto(
|
||||
long Id,
|
||||
string NameFa,
|
||||
string NameEn,
|
||||
int SortOrder,
|
||||
bool IsActive);
|
||||
@@ -0,0 +1,13 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Catalog;
|
||||
|
||||
/// <summary>An admin catalog category. <c>NameFa</c> is primary; the client picks the label by locale.</summary>
|
||||
public record ServiceCategoryDto(
|
||||
long Id,
|
||||
string NameFa,
|
||||
string NameEn,
|
||||
string? DescriptionFa,
|
||||
string? DescriptionEn,
|
||||
string? IconKey,
|
||||
int SortOrder,
|
||||
bool IsActive);
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Baya.Application.Models.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// A nurse's priced offering with its resolved option-set. <c>Price</c> crosses the wire as a <b>string of
|
||||
/// IRR-Rial digits</b> (integer money, no floats). The engagement total is <c>Price</c> + <c>PriceUnit</c>
|
||||
/// + <c>SessionCount</c> — a downstream consumer derives it, never from price alone.
|
||||
/// </summary>
|
||||
public record VariantDto(
|
||||
long Id,
|
||||
long ServiceCategoryId,
|
||||
string CategoryNameFa,
|
||||
string CategoryNameEn,
|
||||
string Price,
|
||||
string PriceUnit,
|
||||
int? SessionCount,
|
||||
string DisplayName,
|
||||
bool IsActive,
|
||||
IReadOnlyList<VariantOptionDto> Options);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Baya.Application.Models.Catalog;
|
||||
|
||||
/// <summary>One answered dimension of a variant, with both the group and value labels resolved.</summary>
|
||||
public record VariantOptionDto(
|
||||
long OptionGroupId,
|
||||
string GroupNameFa,
|
||||
string GroupNameEn,
|
||||
long OptionValueId,
|
||||
string ValueNameFa,
|
||||
string ValueNameEn);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Baya.Application.Models.Catalog;
|
||||
|
||||
/// <summary>One dimension answered when building a variant: the chosen value for a group. The nurse sends
|
||||
/// one of these per group they answer; the handler validates them against the category's applicable groups.</summary>
|
||||
public record VariantOptionSelection(long OptionGroupId, long OptionValueId);
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Baya.Application.Models.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// The immutable input the variant-snapshot serializer freezes onto a booking (b8) — a variant + its
|
||||
/// resolved options exactly as they are at serialize time. <c>Price</c> is the raw IRR-Rial integer;
|
||||
/// the serializer emits it as a string of digits per the money convention.
|
||||
/// </summary>
|
||||
public record VariantSnapshot(
|
||||
long VariantId,
|
||||
long ServiceCategoryId,
|
||||
string CategoryNameFa,
|
||||
string CategoryNameEn,
|
||||
long Price,
|
||||
string PriceUnit,
|
||||
int? SessionCount,
|
||||
string DisplayName,
|
||||
IReadOnlyList<VariantOptionDto> Options);
|
||||
@@ -1,4 +1,5 @@
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using FluentValidation;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -21,6 +22,9 @@ public static class ServiceCollectionExtension
|
||||
|
||||
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidateCommandBehavior<,>));
|
||||
|
||||
// Pure, stateless serializer that b8 consumes to freeze a variant onto a booking.
|
||||
services.AddSingleton<IVariantSnapshotSerializer, VariantSnapshotSerializer>();
|
||||
|
||||
RegisterCommandValidators(services);
|
||||
|
||||
return services;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using Baya.Domain.Common;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
|
||||
namespace Baya.Domain.Entities.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// The <b>atomic bookable unit</b> of the marketplace: a nurse offering a category with a chosen option
|
||||
/// combination at their own price and price unit. Search (b7), booking (b8), and every money calculation
|
||||
/// operate on a <i>variant</i> — never on "a nurse". A nurse with no active variant is not bookable.
|
||||
/// <para>
|
||||
/// <see cref="Price"/> is <b>IRR Rials as an integer</b> — no float, ever. The engagement total is
|
||||
/// <see cref="Price"/> combined with <see cref="PriceUnit"/> and <see cref="SessionCount"/>; a downstream
|
||||
/// consumer (booking) derives it from all three, never from price alone.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="OptionSetHash"/> is a deterministic hash of the sorted answered
|
||||
/// <c>(option_group_id, option_value_id)</c> pairs. It backs the filtered
|
||||
/// <c>UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL</c> that makes the
|
||||
/// duplicate-listing guard race-safe (a multi-row option-set can't be a plain composite unique index).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class NurseServiceVariant : BaseEntity<long>
|
||||
{
|
||||
public long NurseId { get; set; }
|
||||
public NurseProfile Nurse { get; set; }
|
||||
|
||||
public long ServiceCategoryId { get; set; }
|
||||
public ServiceCategory ServiceCategory { get; set; }
|
||||
|
||||
/// <summary>IRR Rials, integer — never a float/decimal-with-fraction. There is no Toman in the DB.</summary>
|
||||
public long Price { get; set; }
|
||||
|
||||
/// <summary>Closed code set — see <see cref="PriceUnits"/>. The only code enum in the catalog area.</summary>
|
||||
public string PriceUnit { get; set; }
|
||||
|
||||
/// <summary>Number of sessions/units the engagement spans; relevant for <c>per_session</c> and packages.</summary>
|
||||
public int? SessionCount { get; set; }
|
||||
|
||||
/// <summary>Auto-generated from the option labels at create time, but nurse-editable.</summary>
|
||||
public string DisplayName { get; set; }
|
||||
|
||||
/// <summary>Deterministic hash of the sorted answered option-set — the duplicate-listing DB backstop key.</summary>
|
||||
public string OptionSetHash { get; set; }
|
||||
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
public ICollection<NurseServiceVariantOption> Options { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// One answered dimension of a variant: the option value it chose for a given group. One row per
|
||||
/// dimension makes the variant's meaning explicit and queryable. <c>UNIQUE(variant_id, option_group_id)</c>
|
||||
/// enforces <b>one value per dimension per variant</b> — a variant can never answer the same group twice.
|
||||
/// </summary>
|
||||
public class NurseServiceVariantOption : BaseEntity<long>
|
||||
{
|
||||
public long VariantId { get; set; }
|
||||
public NurseServiceVariant Variant { get; set; }
|
||||
|
||||
public long OptionGroupId { get; set; }
|
||||
public ServiceOptionGroup OptionGroup { get; set; }
|
||||
|
||||
public long OptionValueId { get; set; }
|
||||
public ServiceOptionValue OptionValue { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Baya.Domain.Entities.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// The five price units a nurse can price a variant in — the <b>only</b> closed code enum in the catalog
|
||||
/// area (categories, groups, and values are data, never code constants). <c>per_24h</c> (شبانهروزی /
|
||||
/// live-in) and <c>per_day</c> are first-class, not edge cases — Iranian home-nursing sells exactly these
|
||||
/// shapes. Crosses the wire as the stable string code.
|
||||
/// </summary>
|
||||
public static class PriceUnits
|
||||
{
|
||||
public const string PerHour = "per_hour";
|
||||
public const string PerSession = "per_session";
|
||||
public const string PerHalfDay = "per_half_day";
|
||||
public const string PerDay = "per_day";
|
||||
public const string Per24H = "per_24h";
|
||||
|
||||
public static readonly IReadOnlySet<string> All =
|
||||
new HashSet<string> { PerHour, PerSession, PerHalfDay, PerDay, Per24H };
|
||||
|
||||
public static bool IsValid(string value) => value is not null && All.Contains(value);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// An admin-managed top-level care type (Elderly, Post-Surgery, Infant, Chronic, Companionship) — the
|
||||
/// primary search dimension and the first thing a nurse picks when building a variant. Categories are
|
||||
/// <b>data, not code</b>: an admin adds one as a row, never a migration. Deactivate (never delete) so the
|
||||
/// bookings/variants already resting on a category survive — their history lives in the booking snapshot.
|
||||
/// Every row carries the <c>NameFa</c> (primary) + <c>NameEn</c> pair; the client picks by locale.
|
||||
/// </summary>
|
||||
public class ServiceCategory : BaseEntity<long>
|
||||
{
|
||||
public string NameFa { get; set; }
|
||||
public string NameEn { get; set; }
|
||||
|
||||
public string DescriptionFa { get; set; }
|
||||
public string DescriptionEn { get; set; }
|
||||
|
||||
/// <summary>UI glyph key the client maps to an icon; not a business value.</summary>
|
||||
public string IconKey { get; set; }
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
public ICollection<ServiceOptionGroup> OptionGroups { get; set; }
|
||||
public ICollection<NurseServiceVariant> Variants { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// An admin-managed configurable <b>pricing dimension</b> (e.g. نوع شیفت / shift type, تعداد بیمار /
|
||||
/// patient count). This is the EAV skeleton that lets a new dimension ship as rows, not a schema change.
|
||||
/// <para>
|
||||
/// <see cref="ServiceCategoryId"/> == <c>null</c> is a <b>meaningful "cross-category"</b> group: the
|
||||
/// dimension applies to <i>every</i> category (e.g. shift type applies everywhere), not missing data.
|
||||
/// The applicable set for a category is therefore its own groups <b>plus</b> every NULL-category group —
|
||||
/// the required-group check and the duplicate guard must both honour that.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class ServiceOptionGroup : BaseEntity<long>
|
||||
{
|
||||
/// <summary>NULL = cross-category (applies to every category). A real coverage choice, not unset.</summary>
|
||||
public long? ServiceCategoryId { get; set; }
|
||||
public ServiceCategory ServiceCategory { get; set; }
|
||||
|
||||
public string NameFa { get; set; }
|
||||
public string NameEn { get; set; }
|
||||
|
||||
/// <summary>Whether a variant in an applicable category must answer this dimension exactly once.</summary>
|
||||
public bool IsRequired { get; set; }
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
public ICollection<ServiceOptionValue> Values { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// A concrete choice inside a <see cref="ServiceOptionGroup"/> (e.g. شبانهروزی / live-in, ۲ نفر / two
|
||||
/// patients). A variant answers a dimension by referencing exactly one value from that dimension's group.
|
||||
/// Carries the <c>NameFa</c> (primary) + <c>NameEn</c> pair like every catalog row.
|
||||
/// </summary>
|
||||
public class ServiceOptionValue : BaseEntity<long>
|
||||
{
|
||||
public long OptionGroupId { get; set; }
|
||||
public ServiceOptionGroup OptionGroup { get; set; }
|
||||
|
||||
public string NameFa { get; set; }
|
||||
public string NameEn { get; set; }
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user