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,73 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Geography.Commands.CreateCity;
using Baya.Application.Features.Geography.Commands.CreateDistrict;
using Baya.Application.Features.Geography.Commands.CreateProvince;
using Baya.Application.Features.Geography.Commands.SetCityActive;
using Baya.Application.Features.Geography.Commands.SetDistrictActive;
using Baya.Application.Features.Geography.Commands.SetProvinceActive;
using Baya.Application.Features.Geography.Commands.UpdateCity;
using Baya.Application.Features.Geography.Commands.UpdateDistrict;
using Baya.Application.Features.Geography.Commands.UpdateProvince;
using Baya.Application.Models.Geography;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize(ConstantPolicies.DynamicPermission)]
[Display(Description = "Admin: curate the geo hierarchy (create/edit + activate/deactivate; no delete)")]
public sealed class AdminGeoController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<ProvinceDto>]
public async Task<IActionResult> CreateProvince(CreateProvinceCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<ProvinceDto>]
public async Task<IActionResult> UpdateProvince(long id, UpdateProvinceCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> SetProvinceActive(long id, SetProvinceActiveCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType<CityDto>]
public async Task<IActionResult> CreateCity(CreateCityCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<CityDto>]
public async Task<IActionResult> UpdateCity(long id, UpdateCityCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> SetCityActive(long id, SetCityActiveCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType<DistrictDto>]
public async Task<IActionResult> CreateDistrict(CreateDistrictCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<DistrictDto>]
public async Task<IActionResult> UpdateDistrict(long id, UpdateDistrictCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> SetDistrictActive(long id, SetDistrictActiveCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
}
@@ -0,0 +1,49 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Addresses.Commands.CreateAddress;
using Baya.Application.Features.Addresses.Commands.DeleteAddress;
using Baya.Application.Features.Addresses.Commands.SetPrimaryAddress;
using Baya.Application.Features.Addresses.Commands.UpdateAddress;
using Baya.Application.Features.Addresses.Queries.ListMyAddresses;
using Baya.Application.Models.Addresses;
using Baya.Application.Models.Common;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize]
[Display(Description = "The signed-in customer's saved service addresses")]
public sealed class CustomerAddressesController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<CustomerAddressDto>]
public async Task<IActionResult> Create(CreateAddressCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<CustomerAddressDto>]
public async Task<IActionResult> Update(long id, UpdateAddressCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> SetPrimary(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new SetPrimaryAddressCommand(id), cancellationToken));
[HttpDelete("[action]/{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> Delete(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new DeleteAddressCommand(id), cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<PagedResult<CustomerAddressDto>>]
public async Task<IActionResult> List([FromQuery] ListMyAddressesQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
}
@@ -0,0 +1,40 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Geography.Queries.GetGeoTree;
using Baya.Application.Features.Geography.Queries.ListCities;
using Baya.Application.Features.Geography.Queries.ListDistricts;
using Baya.Application.Features.Geography.Queries.ListProvinces;
using Baya.Application.Models.Geography;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Display(Description = "Public geo lookups: the province → city → district cascading dropdowns")]
public sealed class GeoController(ISender sender) : BaseController
{
[HttpGet("[action]")]
[ProducesOkApiResponseType<IReadOnlyList<ProvinceDto>>]
public async Task<IActionResult> Provinces(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new ListProvincesQuery(), cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<IReadOnlyList<CityDto>>]
public async Task<IActionResult> Cities([FromQuery(Name = "province_id")] long provinceId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new ListCitiesQuery(provinceId), cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<IReadOnlyList<DistrictDto>>]
public async Task<IActionResult> Districts([FromQuery(Name = "city_id")] long cityId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new ListDistrictsQuery(cityId), cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<IReadOnlyList<ProvinceTreeDto>>]
public async Task<IActionResult> Tree(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetGeoTreeQuery(), cancellationToken));
}
@@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea;
using Baya.Application.Features.ServiceAreas.Commands.RemoveNurseServiceArea;
using Baya.Application.Features.ServiceAreas.Queries.ListMyServiceAreas;
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize]
[Display(Description = "The signed-in nurse's declared service areas (where they will travel)")]
public sealed class NurseServiceAreasController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<NurseServiceAreaDto>]
public async Task<IActionResult> Add(AddNurseServiceAreaCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpDelete("[action]/{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> Remove(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new RemoveNurseServiceAreaCommand(id), cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<PagedResult<NurseServiceAreaDto>>]
public async Task<IActionResult> List([FromQuery] ListMyServiceAreasQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
}
@@ -18,6 +18,11 @@
},
"ObjectStorage": {
"RootPath": ""
},
"Geocoding": {
"ReturnNullCoordinates": false,
"LowConfidenceMarker": "NO_GEO",
"ResolvedConfidence": 0.9
}
},
"AllowedHosts": "*",
@@ -18,6 +18,11 @@
},
"ObjectStorage": {
"RootPath": ""
},
"Geocoding": {
"ReturnNullCoordinates": false,
"LowConfidenceMarker": "NO_GEO",
"ResolvedConfidence": 0.9
}
},
"AllowedHosts": "*",
@@ -46,6 +46,10 @@ public class BaseController : ControllerBase
return new JsonResult(new ApiResult(false, ApiResultStatusCode.Forbidden, FirstErrorMessage(result)))
{ StatusCode = StatusCodes.Status403Forbidden };
if (result.IsConflict)
return new JsonResult(new ApiResult(false, ApiResultStatusCode.Conflict, FirstErrorMessage(result)))
{ StatusCode = StatusCodes.Status409Conflict };
AddErrors(result);
var badRequestErrors = new ValidationProblemDetails(ModelState);
@@ -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; }
}
@@ -0,0 +1,87 @@
#nullable enable
using System.Text;
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Deterministic, network-free <see cref="IGeocoder"/> — the mock seam. It resolves an address to a
/// stable point jittered around the known city centroid (an unknown city falls back to Iran's centroid),
/// so the same address always yields the same coordinates without any external call. A configured global
/// switch or a per-address marker forces the null-coordinate path so the "saved without a map pin" state
/// is testable. The real implementation swaps to a Neshan/Google geocoding client behind this contract.
/// </summary>
public sealed class MockGeocoder(IOptions<SeamOptions> options) : IGeocoder
{
private readonly GeocodingOptions _options = options.Value.Geocoding;
// A few real city centroids for plausible pins; everything else falls back to Iran's centroid.
private static readonly IReadOnlyDictionary<string, (decimal Lat, decimal Lng)> Centroids =
new Dictionary<string, (decimal, decimal)>(StringComparer.OrdinalIgnoreCase)
{
["Tehran"] = (35.6892m, 51.3890m),
["Karaj"] = (35.8400m, 50.9391m),
["Mashhad"] = (36.2605m, 59.6168m),
["Isfahan"] = (32.6539m, 51.6660m),
["Shiraz"] = (29.5918m, 52.5837m),
["Tabriz"] = (38.0800m, 46.2919m),
["Ahvaz"] = (31.3183m, 48.6706m),
["Qom"] = (34.6416m, 50.8746m),
};
private static readonly (decimal Lat, decimal Lng) IranCentroid = (32.4279m, 53.6880m);
public ValueTask<GeocodeResult> GeocodeAsync(
string addressText,
string cityName,
string? districtName,
CancellationToken cancellationToken = default)
{
var formatted = FormatAddress(addressText, cityName, districtName);
var unresolved =
_options.ReturnNullCoordinates ||
(!string.IsNullOrEmpty(_options.LowConfidenceMarker) &&
addressText is not null &&
addressText.Contains(_options.LowConfidenceMarker, StringComparison.OrdinalIgnoreCase));
if (unresolved)
return ValueTask.FromResult(new GeocodeResult(null, null, formatted, 0.2));
var centroid = Centroids.TryGetValue(cityName ?? string.Empty, out var c) ? c : IranCentroid;
// Deterministic ±~0.045° jitter (~5 km) derived from a stable FNV-1a hash of the full text —
// never string.GetHashCode(), which is randomized per process and would break test determinism.
var seed = StableHash($"{cityName}|{districtName}|{addressText}");
var latOffset = Offset(seed);
var lngOffset = Offset(seed >> 16 ^ seed);
var lat = decimal.Round(centroid.Lat + latOffset, 6);
var lng = decimal.Round(centroid.Lng + lngOffset, 6);
return ValueTask.FromResult(new GeocodeResult(lat, lng, formatted, _options.ResolvedConfidence));
}
private static string FormatAddress(string addressText, string cityName, string? districtName) =>
string.Join("، ", new[] { cityName, districtName, addressText }
.Where(part => !string.IsNullOrWhiteSpace(part)));
// Maps the low 16 bits of the seed to a signed offset in [-0.045, +0.045] degrees.
private static decimal Offset(uint seed) => ((seed & 0xFFFF) / 65535m - 0.5m) * 0.09m;
private static uint StableHash(string value)
{
const uint offsetBasis = 2166136261;
const uint prime = 16777619;
var hash = offsetBasis;
foreach (var b in Encoding.UTF8.GetBytes(value))
{
hash ^= b;
hash *= prime;
}
return hash;
}
}
@@ -11,6 +11,25 @@ public sealed class SeamOptions
public FieldEncryptionOptions FieldEncryption { get; set; } = new();
public ObjectStorageOptions ObjectStorage { get; set; } = new();
public BankOwnershipOptions BankOwnership { get; set; } = new();
public GeocodingOptions Geocoding { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>IGeocoder</c>. By default it resolves every address to a deterministic point around
/// the city centroid. Set <see cref="ReturnNullCoordinates"/> to force the unresolved path globally, or
/// embed <see cref="LowConfidenceMarker"/> in a single address to exercise the "saved without a map pin"
/// UI state per-request. The real vendor implementation ignores these.
/// </summary>
public sealed class GeocodingOptions
{
/// <summary>When true, every geocode returns null coordinates with low confidence.</summary>
public bool ReturnNullCoordinates { get; set; }
/// <summary>An address whose text contains this marker resolves to null coordinates (testability).</summary>
public string LowConfidenceMarker { get; set; } = "NO_GEO";
/// <summary>Confidence returned for a successfully resolved address.</summary>
public double ResolvedConfidence { get; set; } = 0.9;
}
/// <summary>
@@ -32,6 +32,10 @@ public static class ServiceCollectionExtension
// fake match; a real Finnotech/banking-bridge client replaces this registration only.
services.AddSingleton<IBankAccountOwnershipVerifier, MockBankAccountOwnershipVerifier>();
// Address geocoding (backend-phase-4). The mock derives deterministic coordinates around the city
// centroid with no network call; a real Neshan/Google geocoding client replaces this registration.
services.AddSingleton<IGeocoder, MockGeocoder>();
return services;
}
}
@@ -121,5 +121,15 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
builder.Property(a => a.AccountHolderName).HasConversion(encrypted);
builder.Property(a => a.Iban).HasConversion(encrypted);
});
// b4 address PII: street line, postal code and recipient contact are encrypted at rest through
// the same seam; the title label and coordinates are not PII and stay plaintext.
modelBuilder.Entity<CustomerAddress>(builder =>
{
builder.Property(a => a.AddressLine).HasConversion(encrypted);
builder.Property(a => a.PostalCode).HasConversion(encrypted);
builder.Property(a => a.RecipientName).HasConversion(encrypted);
builder.Property(a => a.RecipientPhone).HasConversion(encrypted);
});
}
}
@@ -0,0 +1,30 @@
using Baya.Domain.Entities.Geography;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
internal sealed class CityConfig : IEntityTypeConfiguration<City>
{
public void Configure(EntityTypeBuilder<City> builder)
{
builder.ToTable("Cities", "geo");
builder.Property(c => c.NameFa).HasMaxLength(150).IsRequired();
builder.Property(c => c.NameEn).HasMaxLength(150).IsRequired();
builder.Property(c => c.SortOrder).HasDefaultValue(0);
builder.Property(c => c.IsActive).HasDefaultValue(true);
// Ordered cascading lookup: cities for a province in sort order.
builder.HasIndex(c => new { c.ProvinceId, c.SortOrder });
builder.HasOne(c => c.Province)
.WithMany(p => p.Cities)
.HasForeignKey(c => c.ProvinceId)
.IsRequired();
builder.HasQueryFilter(c => c.DeletedAt == null);
builder.HasData(GeographySeed.Cities());
}
}
@@ -0,0 +1,29 @@
using Baya.Domain.Entities.Geography;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
internal sealed class DistrictConfig : IEntityTypeConfiguration<District>
{
public void Configure(EntityTypeBuilder<District> builder)
{
builder.ToTable("Districts", "geo");
builder.Property(d => d.NameFa).HasMaxLength(150).IsRequired();
builder.Property(d => d.NameEn).HasMaxLength(150).IsRequired();
builder.Property(d => d.SortOrder).HasDefaultValue(0);
builder.Property(d => d.IsActive).HasDefaultValue(true);
builder.HasIndex(d => new { d.CityId, d.SortOrder });
builder.HasOne(d => d.City)
.WithMany(c => c.Districts)
.HasForeignKey(d => d.CityId)
.IsRequired();
builder.HasQueryFilter(d => d.DeletedAt == null);
builder.HasData(GeographySeed.Districts());
}
}
@@ -0,0 +1,109 @@
namespace Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
/// <summary>
/// The one-time province/city/district seed, loaded via <c>HasData</c> so the rows land with the
/// migration on a fresh DB (the b1 seeding path). Ids are fixed and deterministic — city id is
/// <c>100 + provinceId</c>, Tehran's districts are <c>1001…1022</c> — so re-running is idempotent and the
/// model snapshot stays stable. All 31 provinces get their capital city (which covers the product's
/// white-space targets — Tehran, Karaj, Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom, all provincial
/// capitals); only Tehran gets districts at seed time. Adding neighborhoods elsewhere is a later admin
/// insert, never a deploy.
/// </summary>
internal static class GeographySeed
{
public const long TehranProvinceId = 1;
public const long TehranCityId = 101;
// (id, name_fa, name_en, capital_fa, capital_en). Tehran first, then by convention; sort_order = id.
private static readonly (long Id, string NameFa, string NameEn, string CapitalFa, string CapitalEn)[] ProvinceRows =
[
(1, "تهران", "Tehran", "تهران", "Tehran"),
(2, "البرز", "Alborz", "کرج", "Karaj"),
(3, "اصفهان", "Isfahan", "اصفهان", "Isfahan"),
(4, "فارس", "Fars", "شیراز", "Shiraz"),
(5, "خراسان رضوی", "Razavi Khorasan", "مشهد", "Mashhad"),
(6, "آذربایجان شرقی", "East Azerbaijan", "تبریز", "Tabriz"),
(7, "آذربایجان غربی", "West Azerbaijan", "ارومیه", "Urmia"),
(8, "خوزستان", "Khuzestan", "اهواز", "Ahvaz"),
(9, "قم", "Qom", "قم", "Qom"),
(10, "کرمان", "Kerman", "کرمان", "Kerman"),
(11, "گیلان", "Gilan", "رشت", "Rasht"),
(12, "مازندران", "Mazandaran", "ساری", "Sari"),
(13, "مرکزی", "Markazi", "اراک", "Arak"),
(14, "اردبیل", "Ardabil", "اردبیل", "Ardabil"),
(15, "قزوین", "Qazvin", "قزوین", "Qazvin"),
(16, "کرمانشاه", "Kermanshah", "کرمانشاه", "Kermanshah"),
(17, "خراسان شمالی", "North Khorasan", "بجنورد", "Bojnord"),
(18, "خراسان جنوبی", "South Khorasan", "بیرجند", "Birjand"),
(19, "همدان", "Hamadan", "همدان", "Hamadan"),
(20, "کردستان", "Kurdistan", "سنندج", "Sanandaj"),
(21, "لرستان", "Lorestan", "خرم‌آباد", "Khorramabad"),
(22, "گلستان", "Golestan", "گرگان", "Gorgan"),
(23, "هرمزگان", "Hormozgan", "بندرعباس", "Bandar Abbas"),
(24, "بوشهر", "Bushehr", "بوشهر", "Bushehr"),
(25, "زنجان", "Zanjan", "زنجان", "Zanjan"),
(26, "سمنان", "Semnan", "سمنان", "Semnan"),
(27, "یزد", "Yazd", "یزد", "Yazd"),
(28, "سیستان و بلوچستان", "Sistan and Baluchestan", "زاهدان", "Zahedan"),
(29, "چهارمحال و بختیاری", "Chaharmahal and Bakhtiari", "شهرکرد", "Shahrekord"),
(30, "کهگیلویه و بویراحمد", "Kohgiluyeh and Boyer-Ahmad", "یاسوج", "Yasuj"),
(31, "ایلام", "Ilam", "ایلام", "Ilam"),
];
// Tehran's 22 municipal مناطق, as Persian ordinals ("منطقه ۱" … "منطقه ۲۲").
private static readonly string[] PersianDigits = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
public static object[] Provinces()
{
var ts = SeedConstants.Timestamp;
return ProvinceRows
.Select(p => (object)new
{
p.Id,
p.NameFa,
p.NameEn,
SortOrder = (int)p.Id,
IsActive = true,
CreatedAt = ts
})
.ToArray();
}
public static object[] Cities()
{
var ts = SeedConstants.Timestamp;
return ProvinceRows
.Select(p => (object)new
{
Id = 100 + p.Id,
ProvinceId = p.Id,
NameFa = p.CapitalFa,
NameEn = p.CapitalEn,
SortOrder = 1,
IsActive = true,
CreatedAt = ts
})
.ToArray();
}
public static object[] Districts()
{
var ts = SeedConstants.Timestamp;
return Enumerable.Range(1, 22)
.Select(n => (object)new
{
Id = 1000L + n,
CityId = TehranCityId,
NameFa = $"منطقه {ToPersianNumber(n)}",
NameEn = $"District {n}",
SortOrder = n,
IsActive = true,
CreatedAt = ts
})
.ToArray();
}
private static string ToPersianNumber(int value) =>
string.Concat(value.ToString(System.Globalization.CultureInfo.InvariantCulture)
.Select(c => PersianDigits[c - '0']));
}
@@ -0,0 +1,48 @@
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
internal sealed class NurseServiceAreaConfig : IEntityTypeConfiguration<NurseServiceArea>
{
public void Configure(EntityTypeBuilder<NurseServiceArea> builder)
{
builder.ToTable("NurseServiceAreas", "geo");
builder.Property(a => a.IsActive).HasDefaultValue(true);
// UNIQUE(nurse_id, city_id, district_id) that correctly rejects a duplicate whole-city row.
// SQL Server treats NULLs as distinct, so a single unique index would wrongly allow two
// "whole city" rows for the same nurse+city. Split into a filtered pair: one enforces at most one
// whole-city row (district_id IS NULL), the other enforces uniqueness of city+district rows. Both
// exclude soft-deleted rows so a removed area can be re-declared.
builder.HasIndex(a => new { a.NurseId, a.CityId })
.IsUnique()
.HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL")
.HasDatabaseName("UX_NurseServiceAreas_Nurse_City_WholeCity");
builder.HasIndex(a => new { a.NurseId, a.CityId, a.DistrictId })
.IsUnique()
.HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL")
.HasDatabaseName("UX_NurseServiceAreas_Nurse_City_District");
builder.HasOne<NurseProfile>()
.WithMany()
.HasForeignKey(a => a.NurseId)
.IsRequired();
builder.HasOne(a => a.City)
.WithMany()
.HasForeignKey(a => a.CityId)
.IsRequired();
builder.HasOne(a => a.District)
.WithMany()
.HasForeignKey(a => a.DistrictId)
.IsRequired(false);
builder.HasQueryFilter(a => a.DeletedAt == null);
}
}
@@ -0,0 +1,24 @@
using Baya.Domain.Entities.Geography;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
internal sealed class ProvinceConfig : IEntityTypeConfiguration<Province>
{
public void Configure(EntityTypeBuilder<Province> builder)
{
builder.ToTable("Provinces", "geo");
builder.Property(p => p.NameFa).HasMaxLength(150).IsRequired();
builder.Property(p => p.NameEn).HasMaxLength(150).IsRequired();
builder.Property(p => p.SortOrder).HasDefaultValue(0);
builder.Property(p => p.IsActive).HasDefaultValue(true);
builder.HasIndex(p => p.SortOrder);
builder.HasQueryFilter(p => p.DeletedAt == null);
builder.HasData(GeographySeed.Provinces());
}
}
@@ -0,0 +1,46 @@
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.IdentityConfig;
internal sealed class CustomerAddressConfig : IEntityTypeConfiguration<CustomerAddress>
{
public void Configure(EntityTypeBuilder<CustomerAddress> builder)
{
builder.ToTable("CustomerAddresses", "usr");
builder.Property(a => a.Title).HasMaxLength(100).IsRequired();
// address_line, postal_code and recipient contact are encrypted at rest (converters wired in
// ApplicationDbContext). The title and coordinates are not PII and stay plaintext.
builder.Property(a => a.Latitude).HasPrecision(9, 6);
builder.Property(a => a.Longitude).HasPrecision(9, 6);
builder.Property(a => a.IsPrimary).HasDefaultValue(false);
// Exactly one primary address per customer — the authoritative DB backstop the set-primary
// transaction must never trip. Excludes soft-deleted rows.
builder.HasIndex(a => a.CustomerId)
.IsUnique()
.HasFilter("[IsPrimary] = 1 AND [DeletedAt] IS NULL")
.HasDatabaseName("UX_CustomerAddresses_Customer_Primary");
builder.HasOne(a => a.Customer)
.WithMany()
.HasForeignKey(a => a.CustomerId)
.IsRequired();
builder.HasOne(a => a.City)
.WithMany()
.HasForeignKey(a => a.CityId)
.IsRequired();
builder.HasOne(a => a.District)
.WithMany()
.HasForeignKey(a => a.DistrictId)
.IsRequired(false);
builder.HasQueryFilter(a => a.DeletedAt == null);
}
}
@@ -0,0 +1,391 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class GeographyAddressesServiceAreas : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "geo");
migrationBuilder.CreateTable(
name: "Provinces",
schema: "geo",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
NameFa = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
NameEn = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Provinces", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Cities",
schema: "geo",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ProvinceId = table.Column<long>(type: "bigint", nullable: false),
NameFa = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
NameEn = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Cities", x => x.Id);
table.ForeignKey(
name: "FK_Cities_Provinces_ProvinceId",
column: x => x.ProvinceId,
principalSchema: "geo",
principalTable: "Provinces",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Districts",
schema: "geo",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CityId = table.Column<long>(type: "bigint", nullable: false),
NameFa = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
NameEn = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Districts", x => x.Id);
table.ForeignKey(
name: "FK_Districts_Cities_CityId",
column: x => x.CityId,
principalSchema: "geo",
principalTable: "Cities",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "CustomerAddresses",
schema: "usr",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CustomerId = table.Column<long>(type: "bigint", nullable: false),
CityId = table.Column<long>(type: "bigint", nullable: false),
DistrictId = table.Column<long>(type: "bigint", nullable: true),
Title = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
AddressLine = table.Column<string>(type: "nvarchar(max)", nullable: true),
PostalCode = table.Column<string>(type: "nvarchar(max)", nullable: true),
Latitude = table.Column<decimal>(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true),
Longitude = table.Column<decimal>(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true),
IsPrimary = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
RecipientName = table.Column<string>(type: "nvarchar(max)", nullable: true),
RecipientPhone = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CustomerAddresses", x => x.Id);
table.ForeignKey(
name: "FK_CustomerAddresses_Cities_CityId",
column: x => x.CityId,
principalSchema: "geo",
principalTable: "Cities",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_CustomerAddresses_CustomerProfiles_CustomerId",
column: x => x.CustomerId,
principalSchema: "usr",
principalTable: "CustomerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_CustomerAddresses_Districts_DistrictId",
column: x => x.DistrictId,
principalSchema: "geo",
principalTable: "Districts",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "NurseServiceAreas",
schema: "geo",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
NurseId = table.Column<long>(type: "bigint", nullable: false),
CityId = table.Column<long>(type: "bigint", nullable: false),
DistrictId = table.Column<long>(type: "bigint", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NurseServiceAreas", x => x.Id);
table.ForeignKey(
name: "FK_NurseServiceAreas_Cities_CityId",
column: x => x.CityId,
principalSchema: "geo",
principalTable: "Cities",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NurseServiceAreas_Districts_DistrictId",
column: x => x.DistrictId,
principalSchema: "geo",
principalTable: "Districts",
principalColumn: "Id");
table.ForeignKey(
name: "FK_NurseServiceAreas_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.InsertData(
schema: "geo",
table: "Provinces",
columns: new[] { "Id", "CreatedAt", "CreatedById", "DeletedAt", "IsActive", "ModifiedAt", "ModifiedById", "NameEn", "NameFa", "SortOrder" },
values: new object[,]
{
{ 1L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Tehran", "تهران", 1 },
{ 2L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Alborz", "البرز", 2 },
{ 3L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Isfahan", "اصفهان", 3 },
{ 4L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Fars", "فارس", 4 },
{ 5L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Razavi Khorasan", "خراسان رضوی", 5 },
{ 6L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "East Azerbaijan", "آذربایجان شرقی", 6 },
{ 7L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "West Azerbaijan", "آذربایجان غربی", 7 },
{ 8L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Khuzestan", "خوزستان", 8 },
{ 9L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Qom", "قم", 9 },
{ 10L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kerman", "کرمان", 10 },
{ 11L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Gilan", "گیلان", 11 },
{ 12L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Mazandaran", "مازندران", 12 },
{ 13L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Markazi", "مرکزی", 13 },
{ 14L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Ardabil", "اردبیل", 14 },
{ 15L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Qazvin", "قزوین", 15 },
{ 16L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kermanshah", "کرمانشاه", 16 },
{ 17L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "North Khorasan", "خراسان شمالی", 17 },
{ 18L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "South Khorasan", "خراسان جنوبی", 18 },
{ 19L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Hamadan", "همدان", 19 },
{ 20L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kurdistan", "کردستان", 20 },
{ 21L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Lorestan", "لرستان", 21 },
{ 22L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Golestan", "گلستان", 22 },
{ 23L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Hormozgan", "هرمزگان", 23 },
{ 24L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Bushehr", "بوشهر", 24 },
{ 25L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Zanjan", "زنجان", 25 },
{ 26L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Semnan", "سمنان", 26 },
{ 27L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Yazd", "یزد", 27 },
{ 28L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Sistan and Baluchestan", "سیستان و بلوچستان", 28 },
{ 29L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Chaharmahal and Bakhtiari", "چهارمحال و بختیاری", 29 },
{ 30L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kohgiluyeh and Boyer-Ahmad", "کهگیلویه و بویراحمد", 30 },
{ 31L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Ilam", "ایلام", 31 }
});
migrationBuilder.InsertData(
schema: "geo",
table: "Cities",
columns: new[] { "Id", "CreatedAt", "CreatedById", "DeletedAt", "IsActive", "ModifiedAt", "ModifiedById", "NameEn", "NameFa", "ProvinceId", "SortOrder" },
values: new object[,]
{
{ 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Tehran", "تهران", 1L, 1 },
{ 102L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Karaj", "کرج", 2L, 1 },
{ 103L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Isfahan", "اصفهان", 3L, 1 },
{ 104L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Shiraz", "شیراز", 4L, 1 },
{ 105L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Mashhad", "مشهد", 5L, 1 },
{ 106L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Tabriz", "تبریز", 6L, 1 },
{ 107L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Urmia", "ارومیه", 7L, 1 },
{ 108L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Ahvaz", "اهواز", 8L, 1 },
{ 109L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Qom", "قم", 9L, 1 },
{ 110L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kerman", "کرمان", 10L, 1 },
{ 111L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Rasht", "رشت", 11L, 1 },
{ 112L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Sari", "ساری", 12L, 1 },
{ 113L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Arak", "اراک", 13L, 1 },
{ 114L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Ardabil", "اردبیل", 14L, 1 },
{ 115L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Qazvin", "قزوین", 15L, 1 },
{ 116L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kermanshah", "کرمانشاه", 16L, 1 },
{ 117L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Bojnord", "بجنورد", 17L, 1 },
{ 118L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Birjand", "بیرجند", 18L, 1 },
{ 119L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Hamadan", "همدان", 19L, 1 },
{ 120L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Sanandaj", "سنندج", 20L, 1 },
{ 121L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Khorramabad", "خرم‌آباد", 21L, 1 },
{ 122L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Gorgan", "گرگان", 22L, 1 },
{ 123L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Bandar Abbas", "بندرعباس", 23L, 1 },
{ 124L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Bushehr", "بوشهر", 24L, 1 },
{ 125L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Zanjan", "زنجان", 25L, 1 },
{ 126L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Semnan", "سمنان", 26L, 1 },
{ 127L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Yazd", "یزد", 27L, 1 },
{ 128L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Zahedan", "زاهدان", 28L, 1 },
{ 129L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Shahrekord", "شهرکرد", 29L, 1 },
{ 130L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Yasuj", "یاسوج", 30L, 1 },
{ 131L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Ilam", "ایلام", 31L, 1 }
});
migrationBuilder.InsertData(
schema: "geo",
table: "Districts",
columns: new[] { "Id", "CityId", "CreatedAt", "CreatedById", "DeletedAt", "IsActive", "ModifiedAt", "ModifiedById", "NameEn", "NameFa", "SortOrder" },
values: new object[,]
{
{ 1001L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 1", "منطقه ۱", 1 },
{ 1002L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 2", "منطقه ۲", 2 },
{ 1003L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 3", "منطقه ۳", 3 },
{ 1004L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 4", "منطقه ۴", 4 },
{ 1005L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 5", "منطقه ۵", 5 },
{ 1006L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 6", "منطقه ۶", 6 },
{ 1007L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 7", "منطقه ۷", 7 },
{ 1008L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 8", "منطقه ۸", 8 },
{ 1009L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 9", "منطقه ۹", 9 },
{ 1010L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 10", "منطقه ۱۰", 10 },
{ 1011L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 11", "منطقه ۱۱", 11 },
{ 1012L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 12", "منطقه ۱۲", 12 },
{ 1013L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 13", "منطقه ۱۳", 13 },
{ 1014L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 14", "منطقه ۱۴", 14 },
{ 1015L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 15", "منطقه ۱۵", 15 },
{ 1016L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 16", "منطقه ۱۶", 16 },
{ 1017L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 17", "منطقه ۱۷", 17 },
{ 1018L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 18", "منطقه ۱۸", 18 },
{ 1019L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 19", "منطقه ۱۹", 19 },
{ 1020L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 20", "منطقه ۲۰", 20 },
{ 1021L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 21", "منطقه ۲۱", 21 },
{ 1022L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 22", "منطقه ۲۲", 22 }
});
migrationBuilder.CreateIndex(
name: "IX_Cities_ProvinceId_SortOrder",
schema: "geo",
table: "Cities",
columns: new[] { "ProvinceId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_CustomerAddresses_CityId",
schema: "usr",
table: "CustomerAddresses",
column: "CityId");
migrationBuilder.CreateIndex(
name: "IX_CustomerAddresses_DistrictId",
schema: "usr",
table: "CustomerAddresses",
column: "DistrictId");
migrationBuilder.CreateIndex(
name: "UX_CustomerAddresses_Customer_Primary",
schema: "usr",
table: "CustomerAddresses",
column: "CustomerId",
unique: true,
filter: "[IsPrimary] = 1 AND [DeletedAt] IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Districts_CityId_SortOrder",
schema: "geo",
table: "Districts",
columns: new[] { "CityId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_NurseServiceAreas_CityId",
schema: "geo",
table: "NurseServiceAreas",
column: "CityId");
migrationBuilder.CreateIndex(
name: "IX_NurseServiceAreas_DistrictId",
schema: "geo",
table: "NurseServiceAreas",
column: "DistrictId");
migrationBuilder.CreateIndex(
name: "UX_NurseServiceAreas_Nurse_City_District",
schema: "geo",
table: "NurseServiceAreas",
columns: new[] { "NurseId", "CityId", "DistrictId" },
unique: true,
filter: "[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL");
migrationBuilder.CreateIndex(
name: "UX_NurseServiceAreas_Nurse_City_WholeCity",
schema: "geo",
table: "NurseServiceAreas",
columns: new[] { "NurseId", "CityId" },
unique: true,
filter: "[DistrictId] IS NULL AND [DeletedAt] IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Provinces_SortOrder",
schema: "geo",
table: "Provinces",
column: "SortOrder");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CustomerAddresses",
schema: "usr");
migrationBuilder.DropTable(
name: "NurseServiceAreas",
schema: "geo");
migrationBuilder.DropTable(
name: "Districts",
schema: "geo");
migrationBuilder.DropTable(
name: "Cities",
schema: "geo");
migrationBuilder.DropTable(
name: "Provinces",
schema: "geo");
}
}
}
@@ -13,6 +13,9 @@ public class UnitOfWork : 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; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -24,6 +27,9 @@ public class UnitOfWork : IUnitOfWork
CustomerProfileRepository = new CustomerProfileRepository(_db);
PatientRepository = new PatientRepository(_db);
NurseBankAccountRepository = new NurseBankAccountRepository(_db);
GeoRepository = new GeoRepository(_db);
NurseServiceAreaRepository = new NurseServiceAreaRepository(_db);
CustomerAddressRepository = new CustomerAddressRepository(_db);
}
public Task CommitAsync()
@@ -0,0 +1,78 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Addresses;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Identity;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class CustomerAddressRepository : BaseAsyncRepository<CustomerAddress>, ICustomerAddressRepository
{
public CustomerAddressRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(CustomerAddress address, CancellationToken cancellationToken)
=> base.AddAsync(address);
public Task<CustomerAddress> GetOwnedAsync(long id, long customerId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(a => a.Id == id && a.CustomerId == customerId, cancellationToken);
public Task<bool> HasAnyAsync(long customerId, CancellationToken cancellationToken)
=> TableNoTracking.AnyAsync(a => a.CustomerId == customerId, cancellationToken);
public async Task ClearOtherPrimaryAsync(long customerId, long addressId, CancellationToken cancellationToken)
=> await Entities
.Where(a => a.CustomerId == customerId && a.IsPrimary && a.Id != addressId)
.ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsPrimary, false), cancellationToken);
public async Task SetPrimaryAsync(long customerId, long addressId, CancellationToken cancellationToken)
{
// Clear-then-set inside one transaction so the filtered unique index is never momentarily violated.
await using var transaction = await DbContext.Database.BeginTransactionAsync(cancellationToken);
await Entities
.Where(a => a.CustomerId == customerId && a.IsPrimary)
.ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsPrimary, false), cancellationToken);
await Entities
.Where(a => a.Id == addressId && a.CustomerId == customerId)
.ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsPrimary, true), cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
public async Task<PagedResult<CustomerAddressDto>> ListAsync(long customerId, int page, int pageSize, CancellationToken cancellationToken)
{
var query = TableNoTracking.Where(a => a.CustomerId == customerId);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(a => a.IsPrimary)
.ThenByDescending(a => a.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
// The encrypted columns are decrypted by the value converter on materialization — the owner's
// own read is the only path that returns the plaintext address.
.Select(a => new CustomerAddressDto(
a.Id,
a.Title,
a.CityId,
a.City.NameFa,
a.City.NameEn,
a.DistrictId,
a.DistrictId == null ? null : a.District.NameFa,
a.DistrictId == null ? null : a.District.NameEn,
a.AddressLine,
a.PostalCode,
a.Latitude,
a.Longitude,
a.IsPrimary,
a.RecipientName,
a.RecipientPhone))
.ToListAsync(cancellationToken);
return new PagedResult<CustomerAddressDto>(items, total, page, pageSize);
}
}
@@ -0,0 +1,121 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Geography;
using Baya.Domain.Entities.Geography;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class GeoRepository : IGeoRepository
{
private readonly ApplicationDbContext _db;
public GeoRepository(ApplicationDbContext db) => _db = db;
public async Task<IReadOnlyList<ProvinceDto>> ListActiveProvincesAsync(CancellationToken cancellationToken)
=> await _db.Set<Province>()
.AsNoTracking()
.Where(p => p.IsActive)
.OrderBy(p => p.SortOrder)
.Select(p => new ProvinceDto(p.Id, p.NameFa, p.NameEn, p.SortOrder))
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<CityDto>> ListActiveCitiesAsync(long provinceId, CancellationToken cancellationToken)
=> await _db.Set<City>()
.AsNoTracking()
.Where(c => c.ProvinceId == provinceId && c.IsActive && c.Province.IsActive)
.OrderBy(c => c.SortOrder)
.Select(c => new CityDto(c.Id, c.ProvinceId, c.NameFa, c.NameEn, c.SortOrder))
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<DistrictDto>> ListActiveDistrictsAsync(long cityId, CancellationToken cancellationToken)
=> await _db.Set<District>()
.AsNoTracking()
.Where(d => d.CityId == cityId && d.IsActive && d.City.IsActive && d.City.Province.IsActive)
.OrderBy(d => d.SortOrder)
.Select(d => new DistrictDto(d.Id, d.CityId, d.NameFa, d.NameEn, d.SortOrder))
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<ProvinceTreeDto>> GetActiveTreeAsync(CancellationToken cancellationToken)
{
// Three flat ordered reads assembled in memory — avoids a two-level nested collection projection
// (which SQLite can't translate) and keeps the payload cheap for a cached, near-static tree.
var provinces = await _db.Set<Province>()
.AsNoTracking()
.Where(p => p.IsActive)
.OrderBy(p => p.SortOrder)
.Select(p => new { p.Id, p.NameFa, p.NameEn, p.SortOrder })
.ToListAsync(cancellationToken);
var cities = await _db.Set<City>()
.AsNoTracking()
.Where(c => c.IsActive && c.Province.IsActive)
.OrderBy(c => c.SortOrder)
.Select(c => new { c.Id, c.ProvinceId, c.NameFa, c.NameEn, c.SortOrder })
.ToListAsync(cancellationToken);
var districts = await _db.Set<District>()
.AsNoTracking()
.Where(d => d.IsActive && d.City.IsActive && d.City.Province.IsActive)
.OrderBy(d => d.SortOrder)
.Select(d => new DistrictDto(d.Id, d.CityId, d.NameFa, d.NameEn, d.SortOrder))
.ToListAsync(cancellationToken);
var districtsByCity = districts
.GroupBy(d => d.CityId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<DistrictDto>)g.ToList());
var citiesByProvince = cities
.GroupBy(c => c.ProvinceId)
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<CityTreeDto>)g
.Select(c => new CityTreeDto(
c.Id,
c.NameFa,
c.NameEn,
c.SortOrder,
districtsByCity.TryGetValue(c.Id, out var ds) ? ds : []))
.ToList());
return provinces
.Select(p => new ProvinceTreeDto(
p.Id,
p.NameFa,
p.NameEn,
p.SortOrder,
citiesByProvince.TryGetValue(p.Id, out var cs) ? cs : []))
.ToList();
}
public Task<Province> GetProvinceAsync(long id, CancellationToken cancellationToken)
=> _db.Set<Province>().FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
public Task<City> GetCityAsync(long id, CancellationToken cancellationToken)
=> _db.Set<City>().FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
public Task<District> GetDistrictAsync(long id, CancellationToken cancellationToken)
=> _db.Set<District>().FirstOrDefaultAsync(d => d.Id == id, cancellationToken);
public async Task AddProvinceAsync(Province province, CancellationToken cancellationToken)
=> await _db.Set<Province>().AddAsync(province, cancellationToken);
public async Task AddCityAsync(City city, CancellationToken cancellationToken)
=> await _db.Set<City>().AddAsync(city, cancellationToken);
public async Task AddDistrictAsync(District district, CancellationToken cancellationToken)
=> await _db.Set<District>().AddAsync(district, cancellationToken);
public Task<bool> ProvinceExistsAsync(long provinceId, CancellationToken cancellationToken)
=> _db.Set<Province>().AsNoTracking().AnyAsync(p => p.Id == provinceId, cancellationToken);
public Task<bool> IsCityActiveAsync(long cityId, CancellationToken cancellationToken)
=> _db.Set<City>().AsNoTracking()
.AnyAsync(c => c.Id == cityId && c.IsActive && c.Province.IsActive, cancellationToken);
public Task<bool> IsDistrictInActiveCityAsync(long districtId, long cityId, CancellationToken cancellationToken)
=> _db.Set<District>().AsNoTracking()
.AnyAsync(
d => d.Id == districtId && d.CityId == cityId &&
d.IsActive && d.City.IsActive && d.City.Province.IsActive,
cancellationToken);
}
@@ -0,0 +1,52 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Baya.Domain.Entities.Geography;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class NurseServiceAreaRepository : BaseAsyncRepository<NurseServiceArea>, INurseServiceAreaRepository
{
public NurseServiceAreaRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(NurseServiceArea area, CancellationToken cancellationToken)
=> base.AddAsync(area);
public Task<NurseServiceArea> GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(a => a.Id == id && a.NurseId == nurseId, cancellationToken);
public Task<bool> DuplicateExistsAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken)
=> TableNoTracking.AnyAsync(
a => a.NurseId == nurseId && a.CityId == cityId && a.DistrictId == districtId,
cancellationToken);
public async Task<PagedResult<NurseServiceAreaDto>> ListAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken)
{
var query = TableNoTracking.Where(a => a.NurseId == nurseId);
var total = await query.CountAsync(cancellationToken);
var items = await query
// Whole-city rows (district_id NULL) first, then by id.
.OrderByDescending(a => a.DistrictId == null)
.ThenBy(a => a.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(a => new NurseServiceAreaDto(
a.Id,
a.CityId,
a.City.NameFa,
a.City.NameEn,
a.DistrictId,
a.DistrictId == null ? null : a.District.NameFa,
a.DistrictId == null ? null : a.District.NameEn,
a.DistrictId == null,
a.IsActive))
.ToListAsync(cancellationToken);
return new PagedResult<NurseServiceAreaDto>(items, total, page, pageSize);
}
}
@@ -0,0 +1,61 @@
using System.Net;
using System.Net.Http.Json;
namespace Baya.Test.Api;
public class AdminGeoApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
private const long TehranProvinceId = 1;
[Fact]
public async Task CreateCity_ThenDeactivate_HidesWithoutDeleting()
{
var client = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, client, "09129000001");
var create = await client.PostAsJsonAsync("/api/v1/admin_geo/create_city",
new { provinceId = TehranProvinceId, nameFa = "شهر آزمایشی", nameEn = "Test City", sortOrder = 99 });
Assert.Equal(HttpStatusCode.OK, create.StatusCode);
var cityId = (await AuthTestClient.ReadDataAsync(create)).GetProperty("id").GetInt64();
// Public list includes the new active city.
Assert.True(await CityVisible(client, cityId));
// Deactivate → it disappears from the public dropdown, but is not deleted.
var deactivate = await client.PostAsJsonAsync($"/api/v1/admin_geo/set_city_active/{cityId}", new { isActive = false });
Assert.Equal(HttpStatusCode.OK, deactivate.StatusCode);
Assert.False(await CityVisible(client, cityId));
// Reactivate → it returns.
var reactivate = await client.PostAsJsonAsync($"/api/v1/admin_geo/set_city_active/{cityId}", new { isActive = true });
Assert.Equal(HttpStatusCode.OK, reactivate.StatusCode);
Assert.True(await CityVisible(client, cityId));
}
[Fact]
public async Task CreateProvince_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/v1/admin_geo/create_province",
new { nameFa = "x", nameEn = "x", sortOrder = 1 });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task CreateProvince_MissingName_Returns400()
{
var client = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, client, "09129000002");
var response = await client.PostAsJsonAsync("/api/v1/admin_geo/create_province",
new { nameFa = "", nameEn = "", sortOrder = 1 });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private static async Task<bool> CityVisible(HttpClient client, long cityId)
{
var response = await client.GetAsync($"/api/v1/geo/cities?province_id={TehranProvinceId}");
var cities = (await AuthTestClient.ReadDataAsync(response)).EnumerateArray();
return cities.Any(c => c.GetProperty("id").GetInt64() == cityId);
}
}
@@ -0,0 +1,36 @@
using Baya.Application.Contracts.Identity;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Identity.Identity.Manager;
using Microsoft.Extensions.DependencyInjection;
namespace Baya.Test.Api;
/// <summary>
/// Logs a user in as an admin: creates the user and grants the seeded <c>admin</c> role <b>before</b>
/// login so the minted access token already carries the admin role claim (role claims are baked at mint
/// time). The dynamic-permission policy grants full access to the admin role.
/// </summary>
internal static class AdminTestClient
{
public static async Task AuthenticateAsync(BayaApiFactory factory, HttpClient client, string phone)
{
using (var scope = factory.Services.CreateScope())
{
var userManager = scope.ServiceProvider.GetRequiredService<IAppUserManager>();
var roleManager = scope.ServiceProvider.GetRequiredService<AppRoleManager>();
var user = await userManager.GetUserByPhoneNumber(phone);
if (user is null)
{
await userManager.CreateUser(new User { UserName = $"admin_{Guid.NewGuid():N}", PhoneNumber = phone });
user = await userManager.GetUserByPhoneNumber(phone);
}
var adminRole = await roleManager.FindByNameAsync(RoleNames.Admin);
await userManager.AddUserToRoleAsync(user!, adminRole!);
}
var tokens = await AuthTestClient.LoginAsync(factory, client, phone);
AuthTestClient.UseBearer(client, tokens.GetProperty("accessToken").GetString()!);
}
}
@@ -0,0 +1,99 @@
using System.Net;
using System.Net.Http.Json;
namespace Baya.Test.Api;
public class CustomerAddressesApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
private const long TehranCityId = 101;
// ASCII address line so the DbContext's Persian-digit normalization (Fa2En) can't alter the value
// between write and read, keeping the decrypt round-trip assertion exact.
private static object AddressBody(string title, bool isPrimary = false, string addressLine = "Valiasr Street No 10") => new
{
title,
cityId = TehranCityId,
addressLine,
postalCode = "1234567890",
recipientName = "علی",
recipientPhone = "09120000000",
isPrimary
};
[Fact]
public async Task Create_Geocoded_PrimaryFirst_DecryptedForOwner()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09126000001", "customer");
var create = await client.PostAsJsonAsync("/api/v1/customer_addresses/create", AddressBody("خانه", isPrimary: true));
Assert.Equal(HttpStatusCode.OK, create.StatusCode);
var created = await AuthTestClient.ReadDataAsync(create);
Assert.True(created.GetProperty("isPrimary").GetBoolean());
// Geocoded by the IGeocoder mock.
Assert.False(created.GetProperty("latitude").ValueKind == System.Text.Json.JsonValueKind.Null);
// The owner's own read returns the decrypted address.
Assert.Equal("Valiasr Street No 10", created.GetProperty("addressLine").GetString());
var list = await client.GetAsync("/api/v1/customer_addresses/list");
var listData = await AuthTestClient.ReadDataAsync(list);
Assert.Equal(1, listData.GetProperty("total").GetInt32());
Assert.Equal("Valiasr Street No 10",
listData.GetProperty("items")[0].GetProperty("addressLine").GetString());
}
[Fact]
public async Task SecondPrimary_ClearsFirst_SinglePrimaryInvariant()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09126000002", "customer");
var first = await client.PostAsJsonAsync("/api/v1/customer_addresses/create", AddressBody("خانه", isPrimary: true));
var firstId = (await AuthTestClient.ReadDataAsync(first)).GetProperty("id").GetInt64();
var second = await client.PostAsJsonAsync("/api/v1/customer_addresses/create", AddressBody("کار", isPrimary: true));
var secondId = (await AuthTestClient.ReadDataAsync(second)).GetProperty("id").GetInt64();
var list = await client.GetAsync("/api/v1/customer_addresses/list");
var items = (await AuthTestClient.ReadDataAsync(list)).GetProperty("items").EnumerateArray().ToList();
Assert.Single(items, i => i.GetProperty("isPrimary").GetBoolean());
Assert.True(Primary(items, secondId));
Assert.False(Primary(items, firstId));
}
[Fact]
public async Task NoGeoMarker_SavesWithoutCoordinates()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09126000003", "customer");
var create = await client.PostAsJsonAsync("/api/v1/customer_addresses/create",
AddressBody("خانه", isPrimary: true, addressLine: "NO_GEO unresolved address"));
var created = await AuthTestClient.ReadDataAsync(create);
Assert.Equal(System.Text.Json.JsonValueKind.Null, created.GetProperty("latitude").ValueKind);
}
[Fact]
public async Task List_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/customer_addresses/list");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Create_MissingAddressLine_Returns400()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09126000004", "customer");
var response = await client.PostAsJsonAsync("/api/v1/customer_addresses/create",
AddressBody("خانه", addressLine: ""));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private static bool Primary(IEnumerable<System.Text.Json.JsonElement> items, long id) =>
items.Single(i => i.GetProperty("id").GetInt64() == id).GetProperty("isPrimary").GetBoolean();
}
@@ -0,0 +1,71 @@
using System.Net;
namespace Baya.Test.Api;
public class GeoApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
// Seeded ids: Tehran province = 1, Tehran city = 101, Mashhad city = 105 (no districts).
private const long TehranProvinceId = 1;
private const long TehranCityId = 101;
private const long MashhadCityId = 105;
[Fact]
public async Task Provinces_ReturnsSeeded31_TehranFirst()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/geo/provinces");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var provinces = (await AuthTestClient.ReadDataAsync(response)).EnumerateArray().ToList();
Assert.Equal(31, provinces.Count);
Assert.Equal("Tehran", provinces[0].GetProperty("nameEn").GetString());
}
[Fact]
public async Task Cities_ForTehranProvince_IncludesTehran()
{
var client = factory.CreateClient();
var response = await client.GetAsync($"/api/v1/geo/cities?province_id={TehranProvinceId}");
var cities = (await AuthTestClient.ReadDataAsync(response)).EnumerateArray().ToList();
Assert.Contains(cities, c => c.GetProperty("id").GetInt64() == TehranCityId);
}
[Fact]
public async Task Districts_ForTehran_Returns22()
{
var client = factory.CreateClient();
var response = await client.GetAsync($"/api/v1/geo/districts?city_id={TehranCityId}");
var districts = (await AuthTestClient.ReadDataAsync(response)).EnumerateArray().ToList();
Assert.Equal(22, districts.Count);
}
[Fact]
public async Task Districts_ForMashhad_IsEmpty_WholeCityOnly()
{
var client = factory.CreateClient();
var response = await client.GetAsync($"/api/v1/geo/districts?city_id={MashhadCityId}");
var districts = (await AuthTestClient.ReadDataAsync(response)).EnumerateArray().ToList();
Assert.Empty(districts);
}
[Fact]
public async Task Tree_ReturnsActiveHierarchy()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/geo/tree");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var provinces = (await AuthTestClient.ReadDataAsync(response)).EnumerateArray().ToList();
Assert.Equal(31, provinces.Count);
var tehran = provinces.Single(p => p.GetProperty("id").GetInt64() == TehranProvinceId);
Assert.NotEmpty(tehran.GetProperty("cities").EnumerateArray());
}
}
@@ -0,0 +1,66 @@
using System.Net;
using System.Net.Http.Json;
namespace Baya.Test.Api;
public class NurseServiceAreasApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
private const long TehranCityId = 101;
private const long TehranDistrict1 = 1001;
private static async Task SetUpNurseAsync(BayaApiFactory factory, HttpClient client, string phone)
{
await ProfileTestClient.AuthenticateAsync(factory, client, phone, "nurse");
await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert",
new { bio = "b", yearsOfExperience = 5, educationLevel = "", educationField = "", specializationsJson = "[]" });
}
[Fact]
public async Task Add_WholeCity_And_District_WithDuplicatesRejected()
{
var client = factory.CreateClient();
await SetUpNurseAsync(factory, client, "09125000001");
// Whole city (no district).
var wholeCity = await client.PostAsJsonAsync("/api/v1/nurse_service_areas/add", new { cityId = TehranCityId });
Assert.Equal(HttpStatusCode.OK, wholeCity.StatusCode);
Assert.True((await AuthTestClient.ReadDataAsync(wholeCity)).GetProperty("isWholeCity").GetBoolean());
// Duplicate whole city → 409 (not a 500).
var dupWholeCity = await client.PostAsJsonAsync("/api/v1/nurse_service_areas/add", new { cityId = TehranCityId });
Assert.Equal(HttpStatusCode.Conflict, dupWholeCity.StatusCode);
// City + district is a distinct coverage row.
var district = await client.PostAsJsonAsync("/api/v1/nurse_service_areas/add",
new { cityId = TehranCityId, districtId = TehranDistrict1 });
Assert.Equal(HttpStatusCode.OK, district.StatusCode);
Assert.False((await AuthTestClient.ReadDataAsync(district)).GetProperty("isWholeCity").GetBoolean());
// Duplicate district → 409.
var dupDistrict = await client.PostAsJsonAsync("/api/v1/nurse_service_areas/add",
new { cityId = TehranCityId, districtId = TehranDistrict1 });
Assert.Equal(HttpStatusCode.Conflict, dupDistrict.StatusCode);
var list = await client.GetAsync("/api/v1/nurse_service_areas/list");
var data = await AuthTestClient.ReadDataAsync(list);
Assert.Equal(2, data.GetProperty("total").GetInt32());
}
[Fact]
public async Task List_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/nurse_service_areas/list");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Add_InvalidCity_Returns400()
{
var client = factory.CreateClient();
await SetUpNurseAsync(factory, client, "09125000002");
var response = await client.PostAsJsonAsync("/api/v1/nurse_service_areas/add", new { cityId = 0 });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}

Some files were not shown because too many files have changed in this diff Show More