backend phase 4: geography, addresses & nurse service areas
Adds the province -> city -> district reference hierarchy (geo schema, seeded with 31 provinces + capital cities + Tehran's 22 districts), nurse service areas (district_id NULL = whole city, filtered-index-pair uniqueness -> 409), and encrypted, geocoded customer addresses with a single-primary invariant. Introduces the IGeocoder seam (mocked) and 409 Conflict on the result envelope. Public cascading lookups are cached behind a generation-token scheme with invalidate-on-admin-write. One EF migration (GeographyAddressesServiceAreas, applied). Contract + swagger snapshot + handoff/report/registry updated. 103 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Seam for turning a typed postal address into geographic coordinates. The mock derives a deterministic
|
||||
/// point around the city centroid with no network call; the real implementation swaps to a Neshan/Google
|
||||
/// geocoding client behind the same contract. Coordinates are <see cref="decimal"/> (never float) so the
|
||||
/// EVV distance check (b9) that later consumes them is exact.
|
||||
/// </summary>
|
||||
public interface IGeocoder
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the free-text <paramref name="addressText"/> (within the named <paramref name="cityName"/>
|
||||
/// and optional <paramref name="districtName"/>) to coordinates. A low-confidence / unresolvable
|
||||
/// address yields a result with <see cref="GeocodeResult.Latitude"/>/<see cref="GeocodeResult.Longitude"/>
|
||||
/// left <c>null</c> — the address is still saved, just without a map pin.
|
||||
/// </summary>
|
||||
ValueTask<GeocodeResult> GeocodeAsync(
|
||||
string addressText,
|
||||
string cityName,
|
||||
string? districtName,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of a geocoding attempt. <see cref="Latitude"/>/<see cref="Longitude"/> are <c>null</c> when
|
||||
/// the provider could not resolve the address with enough confidence.
|
||||
/// </summary>
|
||||
public sealed record GeocodeResult(
|
||||
decimal? Latitude,
|
||||
decimal? Longitude,
|
||||
string FormattedAddress,
|
||||
double Confidence)
|
||||
{
|
||||
public bool HasCoordinates => Latitude is not null && Longitude is not null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Addresses;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
public interface ICustomerAddressRepository
|
||||
{
|
||||
Task AddAsync(CustomerAddress address, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked, tenancy-scoped lookup — returns the address only if it belongs to
|
||||
/// <paramref name="customerId"/>, else null.</summary>
|
||||
Task<CustomerAddress?> GetOwnedAsync(long id, long customerId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Whether the customer already has at least one address (the first becomes primary).</summary>
|
||||
Task<bool> HasAnyAsync(long customerId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Clears the current primary (if any) other than <paramref name="addressId"/> for the
|
||||
/// customer, in a single transaction, so the filtered <c>UNIQUE(customer_id) WHERE is_primary=1</c>
|
||||
/// index never trips. The caller has already verified tenancy and set the new primary.</summary>
|
||||
Task ClearOtherPrimaryAsync(long customerId, long addressId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Atomically makes <paramref name="addressId"/> primary and clears the prior primary
|
||||
/// (clear-then-set order). The address must already be owned by the customer.</summary>
|
||||
Task SetPrimaryAsync(long customerId, long addressId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>No-tracking, paginated projection of the customer's own addresses (primary first), with the
|
||||
/// PII decrypted for the owner.</summary>
|
||||
Task<PagedResult<CustomerAddressDto>> ListAsync(long customerId, int page, int pageSize, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Geography;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// The province/city/district reference hierarchy: public active-only lookups for the cascading dropdowns
|
||||
/// plus tracked getters/adds for the admin CRUD. Reads are projected + no-tracking; callers cache them.
|
||||
/// </summary>
|
||||
public interface IGeoRepository
|
||||
{
|
||||
Task<IReadOnlyList<ProvinceDto>> ListActiveProvincesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Active cities under an active province, ordered — empty if the province is inactive/absent.</summary>
|
||||
Task<IReadOnlyList<CityDto>> ListActiveCitiesAsync(long provinceId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Active districts under an active city, ordered — empty is valid (whole-city-only city).</summary>
|
||||
Task<IReadOnlyList<DistrictDto>> ListActiveDistrictsAsync(long cityId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The full active province→city→district tree in one payload for the dropdown.</summary>
|
||||
Task<IReadOnlyList<ProvinceTreeDto>> GetActiveTreeAsync(CancellationToken cancellationToken);
|
||||
|
||||
// Admin: tracked lookups (include inactive, exclude soft-deleted) for edit/toggle.
|
||||
Task<Province?> GetProvinceAsync(long id, CancellationToken cancellationToken);
|
||||
Task<City?> GetCityAsync(long id, CancellationToken cancellationToken);
|
||||
Task<District?> GetDistrictAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
Task AddProvinceAsync(Province province, CancellationToken cancellationToken);
|
||||
Task AddCityAsync(City city, CancellationToken cancellationToken);
|
||||
Task AddDistrictAsync(District district, CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> ProvinceExistsAsync(long provinceId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>True when the city exists, is active, and its province is active.</summary>
|
||||
Task<bool> IsCityActiveAsync(long cityId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>True when the district exists, is active, belongs to <paramref name="cityId"/>, and that
|
||||
/// city (and its province) are active.</summary>
|
||||
Task<bool> IsDistrictInActiveCityAsync(long districtId, long cityId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Geography;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
public interface INurseServiceAreaRepository
|
||||
{
|
||||
Task AddAsync(NurseServiceArea area, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked, tenancy-scoped lookup — returns the area only if it belongs to
|
||||
/// <paramref name="nurseId"/>, else null.</summary>
|
||||
Task<NurseServiceArea?> GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Whether the nurse already declared this exact coverage (a NULL <paramref name="districtId"/>
|
||||
/// is the whole-city row) — the clean 409 guard ahead of the filtered unique index backstop.</summary>
|
||||
Task<bool> DuplicateExistsAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>No-tracking, paginated projection of the nurse's own areas with city/district names and the
|
||||
/// "whole city" flag; whole-city rows first, then by id.</summary>
|
||||
Task<Models.Common.PagedResult<NurseServiceAreaDto>> ListAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -9,6 +9,9 @@ public interface IUnitOfWork
|
||||
public ICustomerProfileRepository CustomerProfileRepository { get; }
|
||||
public IPatientRepository PatientRepository { get; }
|
||||
public INurseBankAccountRepository NurseBankAccountRepository { get; }
|
||||
public IGeoRepository GeoRepository { get; }
|
||||
public INurseServiceAreaRepository NurseServiceAreaRepository { get; }
|
||||
public ICustomerAddressRepository CustomerAddressRepository { get; }
|
||||
Task CommitAsync();
|
||||
ValueTask RollBackAsync();
|
||||
}
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Addresses;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.CreateAddress;
|
||||
|
||||
internal sealed class CreateAddressCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IGeocoder geocoder)
|
||||
: IRequestHandler<CreateAddressCommand, OperationResult<CustomerAddressDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<CustomerAddressDto>> Handle(CreateAddressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<CustomerAddressDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<CustomerAddressDto>.ForbiddenResult("Only a customer can save an address.");
|
||||
|
||||
var city = await unitOfWork.GeoRepository.GetCityAsync(request.CityId, cancellationToken);
|
||||
if (city is null || !await unitOfWork.GeoRepository.IsCityActiveAsync(request.CityId, cancellationToken))
|
||||
return OperationResult<CustomerAddressDto>.FailureResult(nameof(request.CityId), "City not found or inactive.");
|
||||
|
||||
District? district = null;
|
||||
if (request.DistrictId is { } districtId)
|
||||
{
|
||||
if (!await unitOfWork.GeoRepository.IsDistrictInActiveCityAsync(districtId, request.CityId, cancellationToken))
|
||||
return OperationResult<CustomerAddressDto>.FailureResult(nameof(request.DistrictId), "District not found in this city, or inactive.");
|
||||
|
||||
district = await unitOfWork.GeoRepository.GetDistrictAsync(districtId, cancellationToken);
|
||||
}
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
|
||||
var isFirst = customerId is not { } existing || !await unitOfWork.CustomerAddressRepository.HasAnyAsync(existing, cancellationToken);
|
||||
var isPrimary = request.IsPrimary || isFirst;
|
||||
|
||||
var geo = await geocoder.GeocodeAsync(request.AddressLine, city.NameEn, district?.NameEn, cancellationToken);
|
||||
|
||||
// Values are set as plaintext; the EF value converter encrypts the PII columns at rest.
|
||||
var address = new CustomerAddress
|
||||
{
|
||||
CityId = request.CityId,
|
||||
DistrictId = request.DistrictId,
|
||||
Title = request.Title,
|
||||
AddressLine = request.AddressLine,
|
||||
PostalCode = request.PostalCode,
|
||||
RecipientName = request.RecipientName,
|
||||
RecipientPhone = request.RecipientPhone,
|
||||
Latitude = geo.Latitude,
|
||||
Longitude = geo.Longitude,
|
||||
IsPrimary = isPrimary
|
||||
};
|
||||
|
||||
if (customerId is { } cid)
|
||||
{
|
||||
address.CustomerId = cid;
|
||||
if (isPrimary)
|
||||
await unitOfWork.CustomerAddressRepository.ClearOtherPrimaryAsync(cid, 0, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// First address before any customer-profile save — provision the thin payer row so the FK is
|
||||
// fixed up on commit (mirrors the b3 patient auto-provision).
|
||||
var profile = new CustomerProfile { UserId = userId };
|
||||
await unitOfWork.CustomerProfileRepository.AddAsync(profile, cancellationToken);
|
||||
address.Customer = profile;
|
||||
}
|
||||
|
||||
await unitOfWork.CustomerAddressRepository.AddAsync(address, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<CustomerAddressDto>.SuccessResult(Build(address, city, district));
|
||||
}
|
||||
|
||||
private static CustomerAddressDto Build(CustomerAddress address, City city, District? district) =>
|
||||
new(
|
||||
address.Id,
|
||||
address.Title,
|
||||
city.Id,
|
||||
city.NameFa,
|
||||
city.NameEn,
|
||||
address.DistrictId,
|
||||
district?.NameFa,
|
||||
district?.NameEn,
|
||||
address.AddressLine,
|
||||
address.PostalCode,
|
||||
address.Latitude,
|
||||
address.Longitude,
|
||||
address.IsPrimary,
|
||||
address.RecipientName,
|
||||
address.RecipientPhone);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.CreateAddress;
|
||||
|
||||
public sealed class CreateAddressCommandValidator : AbstractValidator<CreateAddressCommand>
|
||||
{
|
||||
public CreateAddressCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(100);
|
||||
RuleFor(x => x.CityId).GreaterThan(0);
|
||||
RuleFor(x => x.DistrictId).GreaterThan(0).When(x => x.DistrictId.HasValue);
|
||||
RuleFor(x => x.AddressLine).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.PostalCode)
|
||||
.Matches("^[0-9۰-۹]{10}$")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.PostalCode))
|
||||
.WithMessage("Postal code must be 10 digits.");
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
using Baya.Application.Models.Addresses;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.CreateAddress;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a saved address for the signed-in customer. The street line, postal code and recipient contact
|
||||
/// are encrypted at rest; coordinates are set from the <c>IGeocoder</c> seam. The first address (or one
|
||||
/// created with <c>IsPrimary=true</c>) becomes the single primary. Tenancy is from the caller.
|
||||
/// </summary>
|
||||
public record CreateAddressCommand(
|
||||
string Title,
|
||||
long CityId,
|
||||
long? DistrictId,
|
||||
string AddressLine,
|
||||
string PostalCode,
|
||||
string RecipientName,
|
||||
string RecipientPhone,
|
||||
bool IsPrimary) : IRequest<OperationResult<CustomerAddressDto>>;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
#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.Addresses.Commands.DeleteAddress;
|
||||
|
||||
internal sealed class DeleteAddressCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider clock)
|
||||
: IRequestHandler<DeleteAddressCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(DeleteAddressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<bool>.ForbiddenResult("Only a customer can delete an address.");
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is not { } cid)
|
||||
return OperationResult<bool>.NotFoundResult("Address not found.");
|
||||
|
||||
var address = await unitOfWork.CustomerAddressRepository.GetOwnedAsync(request.Id, cid, cancellationToken);
|
||||
if (address is null)
|
||||
return OperationResult<bool>.NotFoundResult("Address not found.");
|
||||
|
||||
address.DeletedAt = clock.UtcNow;
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.DeleteAddress;
|
||||
|
||||
/// <summary>Soft-deletes one of the signed-in customer's own addresses (tenancy-checked).</summary>
|
||||
public record DeleteAddressCommand(long Id) : IRequest<OperationResult<bool>>;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
#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.Addresses.Commands.SetPrimaryAddress;
|
||||
|
||||
internal sealed class SetPrimaryAddressCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<SetPrimaryAddressCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(SetPrimaryAddressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<bool>.ForbiddenResult("Only a customer can manage addresses.");
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is not { } cid)
|
||||
return OperationResult<bool>.NotFoundResult("Address not found.");
|
||||
|
||||
var address = await unitOfWork.CustomerAddressRepository.GetOwnedAsync(request.Id, cid, cancellationToken);
|
||||
if (address is null)
|
||||
return OperationResult<bool>.NotFoundResult("Address not found.");
|
||||
|
||||
if (!address.IsPrimary)
|
||||
await unitOfWork.CustomerAddressRepository.SetPrimaryAsync(cid, request.Id, cancellationToken);
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.SetPrimaryAddress;
|
||||
|
||||
/// <summary>Atomically makes one of the customer's own addresses primary and clears the previous one
|
||||
/// (the single-primary invariant). Tenancy-checked.</summary>
|
||||
public record SetPrimaryAddressCommand(long Id) : IRequest<OperationResult<bool>>;
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Addresses;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.UpdateAddress;
|
||||
|
||||
internal sealed class UpdateAddressCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IGeocoder geocoder)
|
||||
: IRequestHandler<UpdateAddressCommand, OperationResult<CustomerAddressDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<CustomerAddressDto>> Handle(UpdateAddressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<CustomerAddressDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<CustomerAddressDto>.ForbiddenResult("Only a customer can edit an address.");
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is not { } cid)
|
||||
return OperationResult<CustomerAddressDto>.NotFoundResult("Address not found.");
|
||||
|
||||
var address = await unitOfWork.CustomerAddressRepository.GetOwnedAsync(request.Id, cid, cancellationToken);
|
||||
if (address is null)
|
||||
return OperationResult<CustomerAddressDto>.NotFoundResult("Address not found.");
|
||||
|
||||
var city = await unitOfWork.GeoRepository.GetCityAsync(request.CityId, cancellationToken);
|
||||
if (city is null || !await unitOfWork.GeoRepository.IsCityActiveAsync(request.CityId, cancellationToken))
|
||||
return OperationResult<CustomerAddressDto>.FailureResult(nameof(request.CityId), "City not found or inactive.");
|
||||
|
||||
District? district = null;
|
||||
if (request.DistrictId is { } districtId)
|
||||
{
|
||||
if (!await unitOfWork.GeoRepository.IsDistrictInActiveCityAsync(districtId, request.CityId, cancellationToken))
|
||||
return OperationResult<CustomerAddressDto>.FailureResult(nameof(request.DistrictId), "District not found in this city, or inactive.");
|
||||
|
||||
district = await unitOfWork.GeoRepository.GetDistrictAsync(districtId, cancellationToken);
|
||||
}
|
||||
|
||||
var locationChanged =
|
||||
address.CityId != request.CityId ||
|
||||
address.DistrictId != request.DistrictId ||
|
||||
!string.Equals(address.AddressLine, request.AddressLine, StringComparison.Ordinal);
|
||||
|
||||
address.Title = request.Title;
|
||||
address.CityId = request.CityId;
|
||||
address.DistrictId = request.DistrictId;
|
||||
address.AddressLine = request.AddressLine;
|
||||
address.PostalCode = request.PostalCode;
|
||||
address.RecipientName = request.RecipientName;
|
||||
address.RecipientPhone = request.RecipientPhone;
|
||||
|
||||
if (locationChanged)
|
||||
{
|
||||
var geo = await geocoder.GeocodeAsync(request.AddressLine, city.NameEn, district?.NameEn, cancellationToken);
|
||||
address.Latitude = geo.Latitude;
|
||||
address.Longitude = geo.Longitude;
|
||||
}
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<CustomerAddressDto>.SuccessResult(new CustomerAddressDto(
|
||||
address.Id,
|
||||
address.Title,
|
||||
city.Id,
|
||||
city.NameFa,
|
||||
city.NameEn,
|
||||
address.DistrictId,
|
||||
district?.NameFa,
|
||||
district?.NameEn,
|
||||
address.AddressLine,
|
||||
address.PostalCode,
|
||||
address.Latitude,
|
||||
address.Longitude,
|
||||
address.IsPrimary,
|
||||
address.RecipientName,
|
||||
address.RecipientPhone));
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.UpdateAddress;
|
||||
|
||||
public sealed class UpdateAddressCommandValidator : AbstractValidator<UpdateAddressCommand>
|
||||
{
|
||||
public UpdateAddressCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(100);
|
||||
RuleFor(x => x.CityId).GreaterThan(0);
|
||||
RuleFor(x => x.DistrictId).GreaterThan(0).When(x => x.DistrictId.HasValue);
|
||||
RuleFor(x => x.AddressLine).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.PostalCode)
|
||||
.Matches("^[0-9۰-۹]{10}$")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.PostalCode))
|
||||
.WithMessage("Postal code must be 10 digits.");
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
using Baya.Application.Models.Addresses;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.UpdateAddress;
|
||||
|
||||
/// <summary>Edits an address the signed-in customer owns. <c>Id</c> comes from the route; PII is
|
||||
/// re-encrypted and coordinates are re-geocoded when the street line/city/district changes.</summary>
|
||||
public record UpdateAddressCommand(
|
||||
long Id,
|
||||
string Title,
|
||||
long CityId,
|
||||
long? DistrictId,
|
||||
string AddressLine,
|
||||
string PostalCode,
|
||||
string RecipientName,
|
||||
string RecipientPhone) : IRequest<OperationResult<CustomerAddressDto>>;
|
||||
+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.Addresses;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Queries.ListMyAddresses;
|
||||
|
||||
internal sealed class ListMyAddressesQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<ListMyAddressesQuery, OperationResult<PagedResult<CustomerAddressDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<CustomerAddressDto>>> Handle(ListMyAddressesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<PagedResult<CustomerAddressDto>>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<PagedResult<CustomerAddressDto>>.ForbiddenResult("Only a customer can view addresses.");
|
||||
|
||||
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is not { } cid)
|
||||
return OperationResult<PagedResult<CustomerAddressDto>>.SuccessResult(
|
||||
new PagedResult<CustomerAddressDto>([], 0, page, pageSize));
|
||||
|
||||
var result = await unitOfWork.CustomerAddressRepository.ListAsync(cid, page, pageSize, cancellationToken);
|
||||
return OperationResult<PagedResult<CustomerAddressDto>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using Baya.Application.Models.Addresses;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Queries.ListMyAddresses;
|
||||
|
||||
/// <summary>The signed-in customer's own addresses, primary first (tenancy-scoped, paginated). The PII is
|
||||
/// decrypted for the owner.</summary>
|
||||
public record ListMyAddressesQuery(int Page = 1, int PageSize = 50)
|
||||
: IRequest<OperationResult<PagedResult<CustomerAddressDto>>>;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateCity;
|
||||
|
||||
internal sealed class CreateCityCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<CreateCityCommand, OperationResult<CityDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<CityDto>> Handle(CreateCityCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await unitOfWork.GeoRepository.ProvinceExistsAsync(request.ProvinceId, cancellationToken))
|
||||
return OperationResult<CityDto>.FailureResult(nameof(request.ProvinceId), "Province not found.");
|
||||
|
||||
var city = new City
|
||||
{
|
||||
ProvinceId = request.ProvinceId,
|
||||
NameFa = request.NameFa,
|
||||
NameEn = request.NameEn,
|
||||
SortOrder = request.SortOrder,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await unitOfWork.GeoRepository.AddCityAsync(city, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<CityDto>.SuccessResult(
|
||||
new CityDto(city.Id, city.ProvinceId, city.NameFa, city.NameEn, city.SortOrder));
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateCity;
|
||||
|
||||
public sealed class CreateCityCommandValidator : AbstractValidator<CreateCityCommand>
|
||||
{
|
||||
public CreateCityCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ProvinceId).GreaterThan(0);
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateCity;
|
||||
|
||||
/// <summary>Admin: create a city under a province. Invalidates the geo cache.</summary>
|
||||
public record CreateCityCommand(long ProvinceId, string NameFa, string NameEn, int SortOrder)
|
||||
: IRequest<OperationResult<CityDto>>;
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateDistrict;
|
||||
|
||||
internal sealed class CreateDistrictCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<CreateDistrictCommand, OperationResult<DistrictDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<DistrictDto>> Handle(CreateDistrictCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var city = await unitOfWork.GeoRepository.GetCityAsync(request.CityId, cancellationToken);
|
||||
if (city is null)
|
||||
return OperationResult<DistrictDto>.FailureResult(nameof(request.CityId), "City not found.");
|
||||
|
||||
var district = new District
|
||||
{
|
||||
CityId = request.CityId,
|
||||
NameFa = request.NameFa,
|
||||
NameEn = request.NameEn,
|
||||
SortOrder = request.SortOrder,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await unitOfWork.GeoRepository.AddDistrictAsync(district, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<DistrictDto>.SuccessResult(
|
||||
new DistrictDto(district.Id, district.CityId, district.NameFa, district.NameEn, district.SortOrder));
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateDistrict;
|
||||
|
||||
public sealed class CreateDistrictCommandValidator : AbstractValidator<CreateDistrictCommand>
|
||||
{
|
||||
public CreateDistrictCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.CityId).GreaterThan(0);
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateDistrict;
|
||||
|
||||
/// <summary>Admin: add a district under a city (e.g. a neighborhood outside Tehran). Invalidates cache.</summary>
|
||||
public record CreateDistrictCommand(long CityId, string NameFa, string NameEn, int SortOrder)
|
||||
: IRequest<OperationResult<DistrictDto>>;
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateProvince;
|
||||
|
||||
internal sealed class CreateProvinceCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<CreateProvinceCommand, OperationResult<ProvinceDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<ProvinceDto>> Handle(CreateProvinceCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var province = new Province
|
||||
{
|
||||
NameFa = request.NameFa,
|
||||
NameEn = request.NameEn,
|
||||
SortOrder = request.SortOrder,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await unitOfWork.GeoRepository.AddProvinceAsync(province, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<ProvinceDto>.SuccessResult(
|
||||
new ProvinceDto(province.Id, province.NameFa, province.NameEn, province.SortOrder));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateProvince;
|
||||
|
||||
public sealed class CreateProvinceCommandValidator : AbstractValidator<CreateProvinceCommand>
|
||||
{
|
||||
public CreateProvinceCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateProvince;
|
||||
|
||||
/// <summary>Admin: create a province. New provinces launch by insert — no deploy. Invalidates the geo cache.</summary>
|
||||
public record CreateProvinceCommand(string NameFa, string NameEn, int SortOrder)
|
||||
: IRequest<OperationResult<ProvinceDto>>;
|
||||
+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.Geography.Commands.SetCityActive;
|
||||
|
||||
internal sealed class SetCityActiveCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<SetCityActiveCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(SetCityActiveCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var city = await unitOfWork.GeoRepository.GetCityAsync(request.Id, cancellationToken);
|
||||
if (city is null)
|
||||
return OperationResult<bool>.NotFoundResult("City not found.");
|
||||
|
||||
city.IsActive = request.IsActive;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.SetCityActive;
|
||||
|
||||
/// <summary>Admin: toggle a city's active flag (no delete). A deactivated city — and its districts —
|
||||
/// disappear from the public dropdowns without being deleted. Invalidates cache.</summary>
|
||||
public record SetCityActiveCommand(long Id, bool IsActive) : IRequest<OperationResult<bool>>;
|
||||
+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.Geography.Commands.SetDistrictActive;
|
||||
|
||||
internal sealed class SetDistrictActiveCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<SetDistrictActiveCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(SetDistrictActiveCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var district = await unitOfWork.GeoRepository.GetDistrictAsync(request.Id, cancellationToken);
|
||||
if (district is null)
|
||||
return OperationResult<bool>.NotFoundResult("District not found.");
|
||||
|
||||
district.IsActive = request.IsActive;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.SetDistrictActive;
|
||||
|
||||
/// <summary>Admin: toggle a district's active flag (no delete). Invalidates cache.</summary>
|
||||
public record SetDistrictActiveCommand(long Id, bool IsActive) : IRequest<OperationResult<bool>>;
|
||||
+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.Geography.Commands.SetProvinceActive;
|
||||
|
||||
internal sealed class SetProvinceActiveCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<SetProvinceActiveCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(SetProvinceActiveCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var province = await unitOfWork.GeoRepository.GetProvinceAsync(request.Id, cancellationToken);
|
||||
if (province is null)
|
||||
return OperationResult<bool>.NotFoundResult("Province not found.");
|
||||
|
||||
province.IsActive = request.IsActive;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.SetProvinceActive;
|
||||
|
||||
/// <summary>Admin: toggle a province's active flag (no delete). Deactivating hides its cities/districts
|
||||
/// from the public dropdowns via the active-join filter, without orphaning anything. Invalidates cache.</summary>
|
||||
public record SetProvinceActiveCommand(long Id, bool IsActive) : IRequest<OperationResult<bool>>;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateCity;
|
||||
|
||||
internal sealed class UpdateCityCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<UpdateCityCommand, OperationResult<CityDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<CityDto>> Handle(UpdateCityCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var city = await unitOfWork.GeoRepository.GetCityAsync(request.Id, cancellationToken);
|
||||
if (city is null)
|
||||
return OperationResult<CityDto>.NotFoundResult("City not found.");
|
||||
|
||||
city.NameFa = request.NameFa;
|
||||
city.NameEn = request.NameEn;
|
||||
city.SortOrder = request.SortOrder;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<CityDto>.SuccessResult(
|
||||
new CityDto(city.Id, city.ProvinceId, city.NameFa, city.NameEn, city.SortOrder));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateCity;
|
||||
|
||||
public sealed class UpdateCityCommandValidator : AbstractValidator<UpdateCityCommand>
|
||||
{
|
||||
public UpdateCityCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateCity;
|
||||
|
||||
/// <summary>Admin: edit a city's names/sort order. <c>Id</c> comes from the route. Invalidates cache.</summary>
|
||||
public record UpdateCityCommand(long Id, string NameFa, string NameEn, int SortOrder)
|
||||
: IRequest<OperationResult<CityDto>>;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateDistrict;
|
||||
|
||||
internal sealed class UpdateDistrictCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<UpdateDistrictCommand, OperationResult<DistrictDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<DistrictDto>> Handle(UpdateDistrictCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var district = await unitOfWork.GeoRepository.GetDistrictAsync(request.Id, cancellationToken);
|
||||
if (district is null)
|
||||
return OperationResult<DistrictDto>.NotFoundResult("District not found.");
|
||||
|
||||
district.NameFa = request.NameFa;
|
||||
district.NameEn = request.NameEn;
|
||||
district.SortOrder = request.SortOrder;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<DistrictDto>.SuccessResult(
|
||||
new DistrictDto(district.Id, district.CityId, district.NameFa, district.NameEn, district.SortOrder));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateDistrict;
|
||||
|
||||
public sealed class UpdateDistrictCommandValidator : AbstractValidator<UpdateDistrictCommand>
|
||||
{
|
||||
public UpdateDistrictCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateDistrict;
|
||||
|
||||
/// <summary>Admin: edit a district's names/sort order. <c>Id</c> comes from the route. Invalidates cache.</summary>
|
||||
public record UpdateDistrictCommand(long Id, string NameFa, string NameEn, int SortOrder)
|
||||
: IRequest<OperationResult<DistrictDto>>;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateProvince;
|
||||
|
||||
internal sealed class UpdateProvinceCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<UpdateProvinceCommand, OperationResult<ProvinceDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<ProvinceDto>> Handle(UpdateProvinceCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var province = await unitOfWork.GeoRepository.GetProvinceAsync(request.Id, cancellationToken);
|
||||
if (province is null)
|
||||
return OperationResult<ProvinceDto>.NotFoundResult("Province not found.");
|
||||
|
||||
province.NameFa = request.NameFa;
|
||||
province.NameEn = request.NameEn;
|
||||
province.SortOrder = request.SortOrder;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<ProvinceDto>.SuccessResult(
|
||||
new ProvinceDto(province.Id, province.NameFa, province.NameEn, province.SortOrder));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateProvince;
|
||||
|
||||
public sealed class UpdateProvinceCommandValidator : AbstractValidator<UpdateProvinceCommand>
|
||||
{
|
||||
public UpdateProvinceCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateProvince;
|
||||
|
||||
/// <summary>Admin: edit a province's names/sort order. <c>Id</c> comes from the route. Invalidates cache.</summary>
|
||||
public record UpdateProvinceCommand(long Id, string NameFa, string NameEn, int SortOrder)
|
||||
: IRequest<OperationResult<ProvinceDto>>;
|
||||
@@ -0,0 +1,31 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
|
||||
namespace Baya.Application.Features.Geography;
|
||||
|
||||
/// <summary>
|
||||
/// Cache-key scheme for the read-heavy geo lookups. Every data key is namespaced by a generation token;
|
||||
/// an admin write bumps the token, which orphans all prior geo entries in one move (they lapse by TTL).
|
||||
/// This makes cascade invalidation trivial and correct — deactivating a province instantly hides its
|
||||
/// cities/districts without having to enumerate and evict each child key.
|
||||
/// </summary>
|
||||
internal static class GeoCache
|
||||
{
|
||||
private const string VersionKey = "geo:version";
|
||||
|
||||
// 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 ProvincesKey(string version) => $"geo:{version}:provinces";
|
||||
public static string CitiesKey(string version, long provinceId) => $"geo:{version}:cities:{provinceId}";
|
||||
public static string DistrictsKey(string version, long cityId) => $"geo:{version}:districts:{cityId}";
|
||||
public static string TreeKey(string version) => $"geo:{version}:tree";
|
||||
|
||||
private static string NewToken() => Guid.NewGuid().ToString("N");
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Queries.GetGeoTree;
|
||||
|
||||
internal sealed class GetGeoTreeQueryHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<GetGeoTreeQuery, OperationResult<IReadOnlyList<ProvinceTreeDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<IReadOnlyList<ProvinceTreeDto>>> Handle(GetGeoTreeQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var version = await GeoCache.VersionAsync(cache, cancellationToken);
|
||||
|
||||
var tree = await cache.GetOrCreateAsync(
|
||||
GeoCache.TreeKey(version),
|
||||
async ct => await unitOfWork.GeoRepository.GetActiveTreeAsync(ct),
|
||||
GeoCache.Ttl,
|
||||
cancellationToken);
|
||||
|
||||
return OperationResult<IReadOnlyList<ProvinceTreeDto>>.SuccessResult(tree);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Queries.GetGeoTree;
|
||||
|
||||
/// <summary>The full active province→city→district tree in one cached payload for a single-round-trip
|
||||
/// cascading dropdown. Public.</summary>
|
||||
public record GetGeoTreeQuery : IRequest<OperationResult<IReadOnlyList<ProvinceTreeDto>>>;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Queries.ListCities;
|
||||
|
||||
internal sealed class ListCitiesQueryHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<ListCitiesQuery, OperationResult<IReadOnlyList<CityDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<IReadOnlyList<CityDto>>> Handle(ListCitiesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var version = await GeoCache.VersionAsync(cache, cancellationToken);
|
||||
|
||||
var cities = await cache.GetOrCreateAsync(
|
||||
GeoCache.CitiesKey(version, request.ProvinceId),
|
||||
async ct => await unitOfWork.GeoRepository.ListActiveCitiesAsync(request.ProvinceId, ct),
|
||||
GeoCache.Ttl,
|
||||
cancellationToken);
|
||||
|
||||
return OperationResult<IReadOnlyList<CityDto>>.SuccessResult(cities);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Queries.ListCities;
|
||||
|
||||
/// <summary>Active cities under a province, ordered. Public.</summary>
|
||||
public record ListCitiesQuery(long ProvinceId) : IRequest<OperationResult<IReadOnlyList<CityDto>>>;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Queries.ListDistricts;
|
||||
|
||||
internal sealed class ListDistrictsQueryHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<ListDistrictsQuery, OperationResult<IReadOnlyList<DistrictDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<IReadOnlyList<DistrictDto>>> Handle(ListDistrictsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var version = await GeoCache.VersionAsync(cache, cancellationToken);
|
||||
|
||||
var districts = await cache.GetOrCreateAsync(
|
||||
GeoCache.DistrictsKey(version, request.CityId),
|
||||
async ct => await unitOfWork.GeoRepository.ListActiveDistrictsAsync(request.CityId, ct),
|
||||
GeoCache.Ttl,
|
||||
cancellationToken);
|
||||
|
||||
return OperationResult<IReadOnlyList<DistrictDto>>.SuccessResult(districts);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Queries.ListDistricts;
|
||||
|
||||
/// <summary>Active districts under a city, ordered. An empty list is valid — the city is whole-city-only
|
||||
/// and the caller selects whole-city coverage. Public.</summary>
|
||||
public record ListDistrictsQuery(long CityId) : IRequest<OperationResult<IReadOnlyList<DistrictDto>>>;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Queries.ListProvinces;
|
||||
|
||||
internal sealed class ListProvincesQueryHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<ListProvincesQuery, OperationResult<IReadOnlyList<ProvinceDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<IReadOnlyList<ProvinceDto>>> Handle(ListProvincesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var version = await GeoCache.VersionAsync(cache, cancellationToken);
|
||||
|
||||
var provinces = await cache.GetOrCreateAsync(
|
||||
GeoCache.ProvincesKey(version),
|
||||
async ct => await unitOfWork.GeoRepository.ListActiveProvincesAsync(ct),
|
||||
GeoCache.Ttl,
|
||||
cancellationToken);
|
||||
|
||||
return OperationResult<IReadOnlyList<ProvinceDto>>.SuccessResult(provinces);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Queries.ListProvinces;
|
||||
|
||||
/// <summary>Active provinces, ordered by sort order, for the top of the cascading dropdown. Public.</summary>
|
||||
public record ListProvincesQuery : IRequest<OperationResult<IReadOnlyList<ProvinceDto>>>;
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea;
|
||||
|
||||
internal sealed class AddNurseServiceAreaCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<AddNurseServiceAreaCommand, OperationResult<NurseServiceAreaDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<NurseServiceAreaDto>> Handle(AddNurseServiceAreaCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<NurseServiceAreaDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<NurseServiceAreaDto>.ForbiddenResult("Only a nurse can declare a service area.");
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<NurseServiceAreaDto>.FailureResult("No nurse profile exists yet. Create your profile first.");
|
||||
|
||||
var city = await unitOfWork.GeoRepository.GetCityAsync(request.CityId, cancellationToken);
|
||||
if (city is null || !await unitOfWork.GeoRepository.IsCityActiveAsync(request.CityId, cancellationToken))
|
||||
return OperationResult<NurseServiceAreaDto>.FailureResult(nameof(request.CityId), "City not found or inactive.");
|
||||
|
||||
District? district = null;
|
||||
if (request.DistrictId is { } districtId)
|
||||
{
|
||||
if (!await unitOfWork.GeoRepository.IsDistrictInActiveCityAsync(districtId, request.CityId, cancellationToken))
|
||||
return OperationResult<NurseServiceAreaDto>.FailureResult(nameof(request.DistrictId), "District not found in this city, or inactive.");
|
||||
|
||||
district = await unitOfWork.GeoRepository.GetDistrictAsync(districtId, cancellationToken);
|
||||
}
|
||||
|
||||
// Whole-city and city+district duplicates are both rejected — the pre-check returns a clean 409;
|
||||
// the filtered unique-index pair is the DB backstop.
|
||||
if (await unitOfWork.NurseServiceAreaRepository.DuplicateExistsAsync(nid, request.CityId, request.DistrictId, cancellationToken))
|
||||
return OperationResult<NurseServiceAreaDto>.ConflictResult(
|
||||
request.DistrictId is null
|
||||
? "You already cover this whole city."
|
||||
: "You already cover this district.");
|
||||
|
||||
var area = new NurseServiceArea
|
||||
{
|
||||
NurseId = nid,
|
||||
CityId = request.CityId,
|
||||
DistrictId = request.DistrictId,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
// DEFERRED (b7): this is the write that later fans out nurse_search_index rows. Keep it the single
|
||||
// trigger point — do not build the index here.
|
||||
await unitOfWork.NurseServiceAreaRepository.AddAsync(area, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<NurseServiceAreaDto>.SuccessResult(new NurseServiceAreaDto(
|
||||
area.Id,
|
||||
city.Id,
|
||||
city.NameFa,
|
||||
city.NameEn,
|
||||
area.DistrictId,
|
||||
district?.NameFa,
|
||||
district?.NameEn,
|
||||
area.DistrictId is null,
|
||||
area.IsActive));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea;
|
||||
|
||||
public sealed class AddNurseServiceAreaCommandValidator : AbstractValidator<AddNurseServiceAreaCommand>
|
||||
{
|
||||
public AddNurseServiceAreaCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.CityId).GreaterThan(0);
|
||||
RuleFor(x => x.DistrictId).GreaterThan(0).When(x => x.DistrictId.HasValue);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea;
|
||||
|
||||
/// <summary>
|
||||
/// The signed-in nurse declares coverage. Omitting <see cref="DistrictId"/> (null) means the <b>whole
|
||||
/// city</b> — a deliberate coverage choice. The nurse is derived from the caller, never the body. A
|
||||
/// duplicate (including a duplicate whole-city row) returns 409.
|
||||
/// </summary>
|
||||
public record AddNurseServiceAreaCommand(long CityId, long? DistrictId)
|
||||
: IRequest<OperationResult<NurseServiceAreaDto>>;
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
#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.ServiceAreas.Commands.RemoveNurseServiceArea;
|
||||
|
||||
internal sealed class RemoveNurseServiceAreaCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider clock)
|
||||
: IRequestHandler<RemoveNurseServiceAreaCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(RemoveNurseServiceAreaCommand 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 service areas.");
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<bool>.NotFoundResult("Service area not found.");
|
||||
|
||||
// Tenancy: a non-owned/nonexistent id resolves to null → not-found (existence is not leaked).
|
||||
var area = await unitOfWork.NurseServiceAreaRepository.GetOwnedAsync(request.Id, nid, cancellationToken);
|
||||
if (area is null)
|
||||
return OperationResult<bool>.NotFoundResult("Service area not found.");
|
||||
|
||||
// DEFERRED (b7): triggers nurse_search_index row removal — keep this the single trigger point.
|
||||
area.DeletedAt = clock.UtcNow;
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.ServiceAreas.Commands.RemoveNurseServiceArea;
|
||||
|
||||
/// <summary>Soft-removes one of the signed-in nurse's own service areas (tenancy-checked).</summary>
|
||||
public record RemoveNurseServiceAreaCommand(long Id) : IRequest<OperationResult<bool>>;
|
||||
+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.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.ServiceAreas.Queries.ListMyServiceAreas;
|
||||
|
||||
internal sealed class ListMyServiceAreasQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<ListMyServiceAreasQuery, OperationResult<PagedResult<NurseServiceAreaDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<NurseServiceAreaDto>>> Handle(ListMyServiceAreasQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<PagedResult<NurseServiceAreaDto>>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<PagedResult<NurseServiceAreaDto>>.ForbiddenResult("Only a nurse can view service areas.");
|
||||
|
||||
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<NurseServiceAreaDto>>.SuccessResult(
|
||||
new PagedResult<NurseServiceAreaDto>([], 0, page, pageSize));
|
||||
|
||||
var result = await unitOfWork.NurseServiceAreaRepository.ListAsync(nid, page, pageSize, cancellationToken);
|
||||
return OperationResult<PagedResult<NurseServiceAreaDto>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.ServiceAreas.Queries.ListMyServiceAreas;
|
||||
|
||||
/// <summary>The signed-in nurse's own service areas (tenancy-scoped, paginated, whole-city first).</summary>
|
||||
public record ListMyServiceAreasQuery(int Page = 1, int PageSize = 50)
|
||||
: IRequest<OperationResult<PagedResult<NurseServiceAreaDto>>>;
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Baya.Application.Models.Addresses;
|
||||
|
||||
/// <summary>
|
||||
/// A customer's saved address, returned only in the owner's own read path — the encrypted street address,
|
||||
/// postal code and recipient contact are decrypted here for the owner (they are never surfaced to any
|
||||
/// other actor in this phase). Coordinates are <see cref="decimal"/> and may be null when the address was
|
||||
/// saved without a confident geocode.
|
||||
/// </summary>
|
||||
public record CustomerAddressDto(
|
||||
long Id,
|
||||
string Title,
|
||||
long CityId,
|
||||
string CityNameFa,
|
||||
string CityNameEn,
|
||||
long? DistrictId,
|
||||
string DistrictNameFa,
|
||||
string DistrictNameEn,
|
||||
string AddressLine,
|
||||
string PostalCode,
|
||||
decimal? Latitude,
|
||||
decimal? Longitude,
|
||||
bool IsPrimary,
|
||||
string RecipientName,
|
||||
string RecipientPhone);
|
||||
@@ -26,6 +26,9 @@ public enum ApiResultStatusCode
|
||||
[Display(Name = "Authorization Error")]
|
||||
Forbidden = 403,
|
||||
|
||||
[Display(Name = "Conflict")]
|
||||
Conflict = 409,
|
||||
|
||||
[Display(Name = "Not Acceptable")]
|
||||
NotAcceptable = 406,
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ public class OperationResult<TResult> : IOperationResult
|
||||
/// <summary>Maps to HTTP 403 — e.g. self-assigning an internal admin role (backend-phase-2).</summary>
|
||||
public bool IsForbidden { get; set; }
|
||||
|
||||
/// <summary>Maps to HTTP 409 — a uniqueness/state conflict, e.g. a duplicate nurse service area
|
||||
/// (backend-phase-4). Distinct from a validation 400 so callers can react to the collision.</summary>
|
||||
public bool IsConflict { get; set; }
|
||||
|
||||
public static OperationResult<TResult> SuccessResult(TResult result)
|
||||
{
|
||||
return new OperationResult<TResult> { Result = result, IsSuccess = true };
|
||||
@@ -74,6 +78,15 @@ public class OperationResult<TResult> : IOperationResult
|
||||
return operationResult;
|
||||
}
|
||||
|
||||
public static OperationResult<TResult> ConflictResult(string message)
|
||||
{
|
||||
var operationResult = new OperationResult<TResult> { IsSuccess = false, IsConflict = true };
|
||||
|
||||
operationResult.ErrorMessages.Add(new("GeneralError", message));
|
||||
|
||||
return operationResult;
|
||||
}
|
||||
|
||||
public void AddError(string propertyName, string message)
|
||||
{
|
||||
IsSuccess = false;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Baya.Application.Models.Geography;
|
||||
|
||||
/// <summary>A city option under a province for the cascading dropdown.</summary>
|
||||
public record CityDto(long Id, long ProvinceId, string NameFa, string NameEn, int SortOrder);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Baya.Application.Models.Geography;
|
||||
|
||||
/// <summary>A city node in the geo tree, carrying its active districts (empty when the city has none).</summary>
|
||||
public record CityTreeDto(
|
||||
long Id,
|
||||
string NameFa,
|
||||
string NameEn,
|
||||
int SortOrder,
|
||||
IReadOnlyList<DistrictDto> Districts);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Baya.Application.Models.Geography;
|
||||
|
||||
/// <summary>A district option under a city. An empty district list for a city is a valid result — the
|
||||
/// caller then selects whole-city coverage.</summary>
|
||||
public record DistrictDto(long Id, long CityId, string NameFa, string NameEn, int SortOrder);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Baya.Application.Models.Geography;
|
||||
|
||||
/// <summary>
|
||||
/// A nurse's declared coverage row. <see cref="IsWholeCity"/> is <c>true</c> (and the district fields are
|
||||
/// null) when <c>district_id</c> is NULL — meaning the entire city, a deliberate coverage choice.
|
||||
/// </summary>
|
||||
public record NurseServiceAreaDto(
|
||||
long Id,
|
||||
long CityId,
|
||||
string CityNameFa,
|
||||
string CityNameEn,
|
||||
long? DistrictId,
|
||||
string DistrictNameFa,
|
||||
string DistrictNameEn,
|
||||
bool IsWholeCity,
|
||||
bool IsActive);
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Baya.Application.Models.Geography;
|
||||
|
||||
/// <summary>A province option for the cascading dropdown (active provinces, ordered by sort order).</summary>
|
||||
public record ProvinceDto(long Id, string NameFa, string NameEn, int SortOrder);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Baya.Application.Models.Geography;
|
||||
|
||||
/// <summary>A province node in the geo tree — the full active province→city→district hierarchy served in
|
||||
/// one cached payload for the cascading dropdown.</summary>
|
||||
public record ProvinceTreeDto(
|
||||
long Id,
|
||||
string NameFa,
|
||||
string NameEn,
|
||||
int SortOrder,
|
||||
IReadOnlyList<CityTreeDto> Cities);
|
||||
@@ -0,0 +1,23 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Geography;
|
||||
|
||||
/// <summary>
|
||||
/// A city under a <see cref="Province"/> — the main address/search granularity. A nurse who declares a
|
||||
/// city with no district covers the whole city; search intersects on cities and districts, never on a GPS
|
||||
/// radius.
|
||||
/// </summary>
|
||||
public class City : BaseEntity<long>
|
||||
{
|
||||
public long ProvinceId { get; set; }
|
||||
public Province Province { 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; }
|
||||
|
||||
public ICollection<District> Districts { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Geography;
|
||||
|
||||
/// <summary>
|
||||
/// An optional subdivision of a <see cref="City"/> — Tehran's 22 municipal مناطق, or major neighborhoods
|
||||
/// elsewhere. Optional by design: a city with no districts is a valid whole-city-only region, and adding
|
||||
/// neighborhoods elsewhere is a later admin insert, never a deploy.
|
||||
/// </summary>
|
||||
public class District : BaseEntity<long>
|
||||
{
|
||||
public long CityId { get; set; }
|
||||
public City City { 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; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Geography;
|
||||
|
||||
/// <summary>
|
||||
/// Where a nurse will travel — the membership row search later intersects with a customer's address.
|
||||
/// A row with <see cref="DistrictId"/> == <c>null</c> is a <b>meaningful "entire city"</b> coverage
|
||||
/// choice, not missing data; search treats it as matching every district in that city. Whole-city and
|
||||
/// city+district duplicates are both rejected at the DB level (see the filtered-index pair in the EF
|
||||
/// configuration) — a nurse cannot declare the same coverage twice.
|
||||
/// </summary>
|
||||
public class NurseServiceArea : BaseEntity<long>
|
||||
{
|
||||
public long NurseId { get; set; }
|
||||
|
||||
public long CityId { get; set; }
|
||||
public City City { get; set; }
|
||||
|
||||
/// <summary>NULL = the entire city (a deliberate coverage choice, not a forgotten selection).</summary>
|
||||
public long? DistrictId { get; set; }
|
||||
public District District { get; set; }
|
||||
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Geography;
|
||||
|
||||
/// <summary>
|
||||
/// Top of the geo hierarchy (Iran's 31 استان). Stored as a table — not a static list — so a new region
|
||||
/// launches with an admin insert, and <see cref="SortOrder"/>/<see cref="IsActive"/> drive ordered,
|
||||
/// toggleable cascading dropdowns. Deactivated far more often than deleted, so a toggled-off province
|
||||
/// vanishes from public dropdowns without orphaning the addresses/service areas under it.
|
||||
/// </summary>
|
||||
public class Province : BaseEntity<long>
|
||||
{
|
||||
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; }
|
||||
|
||||
public ICollection<City> Cities { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Baya.Domain.Common;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
|
||||
namespace Baya.Domain.Entities.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// A customer's saved service location. The street address and recipient contact are encrypted PII at
|
||||
/// rest (decrypted only in the owner's own read path); <see cref="Latitude"/>/<see cref="Longitude"/> are
|
||||
/// produced by the <c>IGeocoder</c> seam and later consumed by the EVV distance check (b9). Exactly one
|
||||
/// address per customer is primary — enforced in the handler and by a filtered unique index.
|
||||
/// </summary>
|
||||
public class CustomerAddress : BaseEntity<long>
|
||||
{
|
||||
public long CustomerId { get; set; }
|
||||
public CustomerProfile Customer { get; set; }
|
||||
|
||||
public long CityId { get; set; }
|
||||
public City City { get; set; }
|
||||
|
||||
public long? DistrictId { get; set; }
|
||||
public District District { get; set; }
|
||||
|
||||
/// <summary>A label ("خانه"/"محل کار") — not PII, stored plaintext.</summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>Encrypted at rest.</summary>
|
||||
public string AddressLine { get; set; }
|
||||
|
||||
/// <summary>Encrypted at rest.</summary>
|
||||
public string PostalCode { get; set; }
|
||||
|
||||
public decimal? Latitude { get; set; }
|
||||
public decimal? Longitude { get; set; }
|
||||
|
||||
public bool IsPrimary { get; set; }
|
||||
|
||||
/// <summary>Encrypted at rest.</summary>
|
||||
public string RecipientName { get; set; }
|
||||
|
||||
/// <summary>Encrypted at rest.</summary>
|
||||
public string RecipientPhone { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user