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:
hamid
2026-07-02 16:06:12 +03:30
parent 39a979b1a7
commit 82561c4cc6
113 changed files with 9817 additions and 5 deletions
@@ -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();
}
@@ -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);
}
@@ -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.");
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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.");
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>>;
@@ -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));
}
}
@@ -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);
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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);
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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);
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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);
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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);
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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);
}
}
@@ -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");
}
@@ -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);
}
}
@@ -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>>>;
@@ -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);
}
}
@@ -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>>>;
@@ -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);
}
}
@@ -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>>>;
@@ -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);
}
}
@@ -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>>>;
@@ -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));
}
}
@@ -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);
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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; }
}