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:
+28
-4
@@ -81,15 +81,15 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
|
||||
```
|
||||
src/
|
||||
├── Core/
|
||||
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
|
||||
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + the platform-signal facade contracts + Contracts/Persistence per-domain repositories on IUnitOfWork), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly)
|
||||
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
|
||||
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + the platform-signal facade contracts + Contracts/Persistence per-domain repositories on IUnitOfWork), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly)
|
||||
├── Infrastructure/
|
||||
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service)
|
||||
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
|
||||
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier) + AddCrossCuttingSeams
|
||||
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
|
||||
├── API/
|
||||
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications), appsettings*.json
|
||||
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses), appsettings*.json
|
||||
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
|
||||
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
|
||||
├── Shared/Baya.SharedKernel Extensions + validation base
|
||||
@@ -106,7 +106,7 @@ Application reference Infrastructure or the API — this is a hard rule.
|
||||
|
||||
**Cross-cutting seams.** Application defines mock-able external dependencies as interfaces in
|
||||
`Contracts/Common/` (`IDateTimeProvider`, `IFieldEncryptor`, `ICacheService`, `IObjectStorage`,
|
||||
`INotificationDispatcher`, plus `ICurrentUser`). Their in-memory/local mock implementations live in
|
||||
`INotificationDispatcher`, `IGeocoder`, plus `ICurrentUser`). Their in-memory/local mock implementations live in
|
||||
`Baya.Infrastructure.CrossCutting/Seams/` and are registered by `AddCrossCuttingSeams(configuration)`
|
||||
(config section `Seams`); `ICurrentUser` is registered in the Identity layer. Swapping a mock for a
|
||||
real provider is a registration change — handlers depend only on the contract. Audit fields are
|
||||
@@ -144,6 +144,30 @@ every `AbstractValidator<T>` in the Application assembly as `IValidator<T>` so t
|
||||
`ValidateCommandBehavior` (and the `ModelStateValidationAttribute` controller filter) actually run —
|
||||
route-supplied ids (e.g. `patients/update/{id}`) must therefore **not** be validated in the body command.
|
||||
|
||||
**Geography, addresses & nurse service areas (backend-phase-4).** A new **`geo` schema** holds the
|
||||
`Provinces` 1:N `Cities` 1:N `Districts` reference hierarchy (tables, not code lists — new regions launch
|
||||
by admin insert; `is_active`/`sort_order` drive ordered, toggleable dropdowns) plus `NurseServiceAreas`
|
||||
(where a nurse travels). `usr.CustomerAddresses` (identity-domain) holds saved service locations. Seeded
|
||||
via `HasData` (b1 path): 31 provinces + their capital cities (covers the white-space targets) + Tehran's 22
|
||||
مناطق. Features under `Baya.Application/Features/{Geography|ServiceAreas|Addresses}/`; configs in
|
||||
`Persistence/Configuration/{GeographyConfig|IdentityConfig}/`; per-domain repos (`IGeoRepository`,
|
||||
`INurseServiceAreaRepository`, `ICustomerAddressRepository`) on `IUnitOfWork`. Load-bearing rules:
|
||||
- **`district_id = NULL` means "entire city"** — a real coverage choice, not missing data. Whole-city
|
||||
uniqueness is enforced with a **filtered-index pair** (`UNIQUE(nurse_id, city_id) WHERE district_id IS
|
||||
NULL …` + `UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL …`, both `AND deleted_at
|
||||
IS NULL`), because SQL Server treats NULLs as distinct. A duplicate area returns **409** (`OperationResult.ConflictResult` → new `IsConflict` → `BaseController` 409 mapping).
|
||||
- **Coverage is named districts, not GPS radii.** Address lat/lng exists only for the later EVV distance
|
||||
check (b9); it is never used for coverage matching.
|
||||
- **Single primary address** per customer via filtered `UNIQUE(customer_id) WHERE is_primary=1 AND
|
||||
deleted_at IS NULL` + clear-then-set in one transaction; the first address is primary by default.
|
||||
- **Address PII** (`address_line`, `postal_code`, recipient name/phone) is encrypted at rest through
|
||||
`IFieldEncryptor` (converters in `ApplicationDbContext`); decrypted only in the owner's own read.
|
||||
- **`IGeocoder`** (new seam, `Contracts/Common`; mock `MockGeocoder` in CrossCutting, config
|
||||
`Seams:Geocoding`) turns a typed address into deterministic `decimal` coordinates with no network call;
|
||||
a config switch / `NO_GEO` marker forces the null-coordinate path.
|
||||
- **Reference reads are cached** through `ICacheService` behind a generation-token key scheme (`GeoCache`);
|
||||
any admin geo write bumps the token, invalidating the whole geo cache namespace at once.
|
||||
|
||||
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
|
||||
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
|
||||
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Addresses;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.CreateAddress;
|
||||
|
||||
internal sealed class CreateAddressCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IGeocoder geocoder)
|
||||
: IRequestHandler<CreateAddressCommand, OperationResult<CustomerAddressDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<CustomerAddressDto>> Handle(CreateAddressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<CustomerAddressDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<CustomerAddressDto>.ForbiddenResult("Only a customer can save an address.");
|
||||
|
||||
var city = await unitOfWork.GeoRepository.GetCityAsync(request.CityId, cancellationToken);
|
||||
if (city is null || !await unitOfWork.GeoRepository.IsCityActiveAsync(request.CityId, cancellationToken))
|
||||
return OperationResult<CustomerAddressDto>.FailureResult(nameof(request.CityId), "City not found or inactive.");
|
||||
|
||||
District? district = null;
|
||||
if (request.DistrictId is { } districtId)
|
||||
{
|
||||
if (!await unitOfWork.GeoRepository.IsDistrictInActiveCityAsync(districtId, request.CityId, cancellationToken))
|
||||
return OperationResult<CustomerAddressDto>.FailureResult(nameof(request.DistrictId), "District not found in this city, or inactive.");
|
||||
|
||||
district = await unitOfWork.GeoRepository.GetDistrictAsync(districtId, cancellationToken);
|
||||
}
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
|
||||
var isFirst = customerId is not { } existing || !await unitOfWork.CustomerAddressRepository.HasAnyAsync(existing, cancellationToken);
|
||||
var isPrimary = request.IsPrimary || isFirst;
|
||||
|
||||
var geo = await geocoder.GeocodeAsync(request.AddressLine, city.NameEn, district?.NameEn, cancellationToken);
|
||||
|
||||
// Values are set as plaintext; the EF value converter encrypts the PII columns at rest.
|
||||
var address = new CustomerAddress
|
||||
{
|
||||
CityId = request.CityId,
|
||||
DistrictId = request.DistrictId,
|
||||
Title = request.Title,
|
||||
AddressLine = request.AddressLine,
|
||||
PostalCode = request.PostalCode,
|
||||
RecipientName = request.RecipientName,
|
||||
RecipientPhone = request.RecipientPhone,
|
||||
Latitude = geo.Latitude,
|
||||
Longitude = geo.Longitude,
|
||||
IsPrimary = isPrimary
|
||||
};
|
||||
|
||||
if (customerId is { } cid)
|
||||
{
|
||||
address.CustomerId = cid;
|
||||
if (isPrimary)
|
||||
await unitOfWork.CustomerAddressRepository.ClearOtherPrimaryAsync(cid, 0, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// First address before any customer-profile save — provision the thin payer row so the FK is
|
||||
// fixed up on commit (mirrors the b3 patient auto-provision).
|
||||
var profile = new CustomerProfile { UserId = userId };
|
||||
await unitOfWork.CustomerProfileRepository.AddAsync(profile, cancellationToken);
|
||||
address.Customer = profile;
|
||||
}
|
||||
|
||||
await unitOfWork.CustomerAddressRepository.AddAsync(address, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<CustomerAddressDto>.SuccessResult(Build(address, city, district));
|
||||
}
|
||||
|
||||
private static CustomerAddressDto Build(CustomerAddress address, City city, District? district) =>
|
||||
new(
|
||||
address.Id,
|
||||
address.Title,
|
||||
city.Id,
|
||||
city.NameFa,
|
||||
city.NameEn,
|
||||
address.DistrictId,
|
||||
district?.NameFa,
|
||||
district?.NameEn,
|
||||
address.AddressLine,
|
||||
address.PostalCode,
|
||||
address.Latitude,
|
||||
address.Longitude,
|
||||
address.IsPrimary,
|
||||
address.RecipientName,
|
||||
address.RecipientPhone);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.CreateAddress;
|
||||
|
||||
public sealed class CreateAddressCommandValidator : AbstractValidator<CreateAddressCommand>
|
||||
{
|
||||
public CreateAddressCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(100);
|
||||
RuleFor(x => x.CityId).GreaterThan(0);
|
||||
RuleFor(x => x.DistrictId).GreaterThan(0).When(x => x.DistrictId.HasValue);
|
||||
RuleFor(x => x.AddressLine).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.PostalCode)
|
||||
.Matches("^[0-9۰-۹]{10}$")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.PostalCode))
|
||||
.WithMessage("Postal code must be 10 digits.");
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
using Baya.Application.Models.Addresses;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.CreateAddress;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a saved address for the signed-in customer. The street line, postal code and recipient contact
|
||||
/// are encrypted at rest; coordinates are set from the <c>IGeocoder</c> seam. The first address (or one
|
||||
/// created with <c>IsPrimary=true</c>) becomes the single primary. Tenancy is from the caller.
|
||||
/// </summary>
|
||||
public record CreateAddressCommand(
|
||||
string Title,
|
||||
long CityId,
|
||||
long? DistrictId,
|
||||
string AddressLine,
|
||||
string PostalCode,
|
||||
string RecipientName,
|
||||
string RecipientPhone,
|
||||
bool IsPrimary) : IRequest<OperationResult<CustomerAddressDto>>;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.DeleteAddress;
|
||||
|
||||
internal sealed class DeleteAddressCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider clock)
|
||||
: IRequestHandler<DeleteAddressCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(DeleteAddressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<bool>.ForbiddenResult("Only a customer can delete an address.");
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is not { } cid)
|
||||
return OperationResult<bool>.NotFoundResult("Address not found.");
|
||||
|
||||
var address = await unitOfWork.CustomerAddressRepository.GetOwnedAsync(request.Id, cid, cancellationToken);
|
||||
if (address is null)
|
||||
return OperationResult<bool>.NotFoundResult("Address not found.");
|
||||
|
||||
address.DeletedAt = clock.UtcNow;
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.DeleteAddress;
|
||||
|
||||
/// <summary>Soft-deletes one of the signed-in customer's own addresses (tenancy-checked).</summary>
|
||||
public record DeleteAddressCommand(long Id) : IRequest<OperationResult<bool>>;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.SetPrimaryAddress;
|
||||
|
||||
internal sealed class SetPrimaryAddressCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<SetPrimaryAddressCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(SetPrimaryAddressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<bool>.ForbiddenResult("Only a customer can manage addresses.");
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is not { } cid)
|
||||
return OperationResult<bool>.NotFoundResult("Address not found.");
|
||||
|
||||
var address = await unitOfWork.CustomerAddressRepository.GetOwnedAsync(request.Id, cid, cancellationToken);
|
||||
if (address is null)
|
||||
return OperationResult<bool>.NotFoundResult("Address not found.");
|
||||
|
||||
if (!address.IsPrimary)
|
||||
await unitOfWork.CustomerAddressRepository.SetPrimaryAsync(cid, request.Id, cancellationToken);
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.SetPrimaryAddress;
|
||||
|
||||
/// <summary>Atomically makes one of the customer's own addresses primary and clears the previous one
|
||||
/// (the single-primary invariant). Tenancy-checked.</summary>
|
||||
public record SetPrimaryAddressCommand(long Id) : IRequest<OperationResult<bool>>;
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Addresses;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.UpdateAddress;
|
||||
|
||||
internal sealed class UpdateAddressCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IGeocoder geocoder)
|
||||
: IRequestHandler<UpdateAddressCommand, OperationResult<CustomerAddressDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<CustomerAddressDto>> Handle(UpdateAddressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<CustomerAddressDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<CustomerAddressDto>.ForbiddenResult("Only a customer can edit an address.");
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is not { } cid)
|
||||
return OperationResult<CustomerAddressDto>.NotFoundResult("Address not found.");
|
||||
|
||||
var address = await unitOfWork.CustomerAddressRepository.GetOwnedAsync(request.Id, cid, cancellationToken);
|
||||
if (address is null)
|
||||
return OperationResult<CustomerAddressDto>.NotFoundResult("Address not found.");
|
||||
|
||||
var city = await unitOfWork.GeoRepository.GetCityAsync(request.CityId, cancellationToken);
|
||||
if (city is null || !await unitOfWork.GeoRepository.IsCityActiveAsync(request.CityId, cancellationToken))
|
||||
return OperationResult<CustomerAddressDto>.FailureResult(nameof(request.CityId), "City not found or inactive.");
|
||||
|
||||
District? district = null;
|
||||
if (request.DistrictId is { } districtId)
|
||||
{
|
||||
if (!await unitOfWork.GeoRepository.IsDistrictInActiveCityAsync(districtId, request.CityId, cancellationToken))
|
||||
return OperationResult<CustomerAddressDto>.FailureResult(nameof(request.DistrictId), "District not found in this city, or inactive.");
|
||||
|
||||
district = await unitOfWork.GeoRepository.GetDistrictAsync(districtId, cancellationToken);
|
||||
}
|
||||
|
||||
var locationChanged =
|
||||
address.CityId != request.CityId ||
|
||||
address.DistrictId != request.DistrictId ||
|
||||
!string.Equals(address.AddressLine, request.AddressLine, StringComparison.Ordinal);
|
||||
|
||||
address.Title = request.Title;
|
||||
address.CityId = request.CityId;
|
||||
address.DistrictId = request.DistrictId;
|
||||
address.AddressLine = request.AddressLine;
|
||||
address.PostalCode = request.PostalCode;
|
||||
address.RecipientName = request.RecipientName;
|
||||
address.RecipientPhone = request.RecipientPhone;
|
||||
|
||||
if (locationChanged)
|
||||
{
|
||||
var geo = await geocoder.GeocodeAsync(request.AddressLine, city.NameEn, district?.NameEn, cancellationToken);
|
||||
address.Latitude = geo.Latitude;
|
||||
address.Longitude = geo.Longitude;
|
||||
}
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<CustomerAddressDto>.SuccessResult(new CustomerAddressDto(
|
||||
address.Id,
|
||||
address.Title,
|
||||
city.Id,
|
||||
city.NameFa,
|
||||
city.NameEn,
|
||||
address.DistrictId,
|
||||
district?.NameFa,
|
||||
district?.NameEn,
|
||||
address.AddressLine,
|
||||
address.PostalCode,
|
||||
address.Latitude,
|
||||
address.Longitude,
|
||||
address.IsPrimary,
|
||||
address.RecipientName,
|
||||
address.RecipientPhone));
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.UpdateAddress;
|
||||
|
||||
public sealed class UpdateAddressCommandValidator : AbstractValidator<UpdateAddressCommand>
|
||||
{
|
||||
public UpdateAddressCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(100);
|
||||
RuleFor(x => x.CityId).GreaterThan(0);
|
||||
RuleFor(x => x.DistrictId).GreaterThan(0).When(x => x.DistrictId.HasValue);
|
||||
RuleFor(x => x.AddressLine).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.PostalCode)
|
||||
.Matches("^[0-9۰-۹]{10}$")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.PostalCode))
|
||||
.WithMessage("Postal code must be 10 digits.");
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
using Baya.Application.Models.Addresses;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Commands.UpdateAddress;
|
||||
|
||||
/// <summary>Edits an address the signed-in customer owns. <c>Id</c> comes from the route; PII is
|
||||
/// re-encrypted and coordinates are re-geocoded when the street line/city/district changes.</summary>
|
||||
public record UpdateAddressCommand(
|
||||
long Id,
|
||||
string Title,
|
||||
long CityId,
|
||||
long? DistrictId,
|
||||
string AddressLine,
|
||||
string PostalCode,
|
||||
string RecipientName,
|
||||
string RecipientPhone) : IRequest<OperationResult<CustomerAddressDto>>;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Addresses;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Queries.ListMyAddresses;
|
||||
|
||||
internal sealed class ListMyAddressesQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<ListMyAddressesQuery, OperationResult<PagedResult<CustomerAddressDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<CustomerAddressDto>>> Handle(ListMyAddressesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<PagedResult<CustomerAddressDto>>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<PagedResult<CustomerAddressDto>>.ForbiddenResult("Only a customer can view addresses.");
|
||||
|
||||
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is not { } cid)
|
||||
return OperationResult<PagedResult<CustomerAddressDto>>.SuccessResult(
|
||||
new PagedResult<CustomerAddressDto>([], 0, page, pageSize));
|
||||
|
||||
var result = await unitOfWork.CustomerAddressRepository.ListAsync(cid, page, pageSize, cancellationToken);
|
||||
return OperationResult<PagedResult<CustomerAddressDto>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using Baya.Application.Models.Addresses;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Addresses.Queries.ListMyAddresses;
|
||||
|
||||
/// <summary>The signed-in customer's own addresses, primary first (tenancy-scoped, paginated). The PII is
|
||||
/// decrypted for the owner.</summary>
|
||||
public record ListMyAddressesQuery(int Page = 1, int PageSize = 50)
|
||||
: IRequest<OperationResult<PagedResult<CustomerAddressDto>>>;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateCity;
|
||||
|
||||
internal sealed class CreateCityCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<CreateCityCommand, OperationResult<CityDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<CityDto>> Handle(CreateCityCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await unitOfWork.GeoRepository.ProvinceExistsAsync(request.ProvinceId, cancellationToken))
|
||||
return OperationResult<CityDto>.FailureResult(nameof(request.ProvinceId), "Province not found.");
|
||||
|
||||
var city = new City
|
||||
{
|
||||
ProvinceId = request.ProvinceId,
|
||||
NameFa = request.NameFa,
|
||||
NameEn = request.NameEn,
|
||||
SortOrder = request.SortOrder,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await unitOfWork.GeoRepository.AddCityAsync(city, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<CityDto>.SuccessResult(
|
||||
new CityDto(city.Id, city.ProvinceId, city.NameFa, city.NameEn, city.SortOrder));
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateCity;
|
||||
|
||||
public sealed class CreateCityCommandValidator : AbstractValidator<CreateCityCommand>
|
||||
{
|
||||
public CreateCityCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ProvinceId).GreaterThan(0);
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateCity;
|
||||
|
||||
/// <summary>Admin: create a city under a province. Invalidates the geo cache.</summary>
|
||||
public record CreateCityCommand(long ProvinceId, string NameFa, string NameEn, int SortOrder)
|
||||
: IRequest<OperationResult<CityDto>>;
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateDistrict;
|
||||
|
||||
internal sealed class CreateDistrictCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<CreateDistrictCommand, OperationResult<DistrictDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<DistrictDto>> Handle(CreateDistrictCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var city = await unitOfWork.GeoRepository.GetCityAsync(request.CityId, cancellationToken);
|
||||
if (city is null)
|
||||
return OperationResult<DistrictDto>.FailureResult(nameof(request.CityId), "City not found.");
|
||||
|
||||
var district = new District
|
||||
{
|
||||
CityId = request.CityId,
|
||||
NameFa = request.NameFa,
|
||||
NameEn = request.NameEn,
|
||||
SortOrder = request.SortOrder,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await unitOfWork.GeoRepository.AddDistrictAsync(district, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<DistrictDto>.SuccessResult(
|
||||
new DistrictDto(district.Id, district.CityId, district.NameFa, district.NameEn, district.SortOrder));
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateDistrict;
|
||||
|
||||
public sealed class CreateDistrictCommandValidator : AbstractValidator<CreateDistrictCommand>
|
||||
{
|
||||
public CreateDistrictCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.CityId).GreaterThan(0);
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateDistrict;
|
||||
|
||||
/// <summary>Admin: add a district under a city (e.g. a neighborhood outside Tehran). Invalidates cache.</summary>
|
||||
public record CreateDistrictCommand(long CityId, string NameFa, string NameEn, int SortOrder)
|
||||
: IRequest<OperationResult<DistrictDto>>;
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateProvince;
|
||||
|
||||
internal sealed class CreateProvinceCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<CreateProvinceCommand, OperationResult<ProvinceDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<ProvinceDto>> Handle(CreateProvinceCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var province = new Province
|
||||
{
|
||||
NameFa = request.NameFa,
|
||||
NameEn = request.NameEn,
|
||||
SortOrder = request.SortOrder,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await unitOfWork.GeoRepository.AddProvinceAsync(province, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<ProvinceDto>.SuccessResult(
|
||||
new ProvinceDto(province.Id, province.NameFa, province.NameEn, province.SortOrder));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateProvince;
|
||||
|
||||
public sealed class CreateProvinceCommandValidator : AbstractValidator<CreateProvinceCommand>
|
||||
{
|
||||
public CreateProvinceCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.CreateProvince;
|
||||
|
||||
/// <summary>Admin: create a province. New provinces launch by insert — no deploy. Invalidates the geo cache.</summary>
|
||||
public record CreateProvinceCommand(string NameFa, string NameEn, int SortOrder)
|
||||
: IRequest<OperationResult<ProvinceDto>>;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.SetCityActive;
|
||||
|
||||
internal sealed class SetCityActiveCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<SetCityActiveCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(SetCityActiveCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var city = await unitOfWork.GeoRepository.GetCityAsync(request.Id, cancellationToken);
|
||||
if (city is null)
|
||||
return OperationResult<bool>.NotFoundResult("City not found.");
|
||||
|
||||
city.IsActive = request.IsActive;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.SetCityActive;
|
||||
|
||||
/// <summary>Admin: toggle a city's active flag (no delete). A deactivated city — and its districts —
|
||||
/// disappear from the public dropdowns without being deleted. Invalidates cache.</summary>
|
||||
public record SetCityActiveCommand(long Id, bool IsActive) : IRequest<OperationResult<bool>>;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.SetDistrictActive;
|
||||
|
||||
internal sealed class SetDistrictActiveCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<SetDistrictActiveCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(SetDistrictActiveCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var district = await unitOfWork.GeoRepository.GetDistrictAsync(request.Id, cancellationToken);
|
||||
if (district is null)
|
||||
return OperationResult<bool>.NotFoundResult("District not found.");
|
||||
|
||||
district.IsActive = request.IsActive;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.SetDistrictActive;
|
||||
|
||||
/// <summary>Admin: toggle a district's active flag (no delete). Invalidates cache.</summary>
|
||||
public record SetDistrictActiveCommand(long Id, bool IsActive) : IRequest<OperationResult<bool>>;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.SetProvinceActive;
|
||||
|
||||
internal sealed class SetProvinceActiveCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<SetProvinceActiveCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(SetProvinceActiveCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var province = await unitOfWork.GeoRepository.GetProvinceAsync(request.Id, cancellationToken);
|
||||
if (province is null)
|
||||
return OperationResult<bool>.NotFoundResult("Province not found.");
|
||||
|
||||
province.IsActive = request.IsActive;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.SetProvinceActive;
|
||||
|
||||
/// <summary>Admin: toggle a province's active flag (no delete). Deactivating hides its cities/districts
|
||||
/// from the public dropdowns via the active-join filter, without orphaning anything. Invalidates cache.</summary>
|
||||
public record SetProvinceActiveCommand(long Id, bool IsActive) : IRequest<OperationResult<bool>>;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateCity;
|
||||
|
||||
internal sealed class UpdateCityCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<UpdateCityCommand, OperationResult<CityDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<CityDto>> Handle(UpdateCityCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var city = await unitOfWork.GeoRepository.GetCityAsync(request.Id, cancellationToken);
|
||||
if (city is null)
|
||||
return OperationResult<CityDto>.NotFoundResult("City not found.");
|
||||
|
||||
city.NameFa = request.NameFa;
|
||||
city.NameEn = request.NameEn;
|
||||
city.SortOrder = request.SortOrder;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<CityDto>.SuccessResult(
|
||||
new CityDto(city.Id, city.ProvinceId, city.NameFa, city.NameEn, city.SortOrder));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateCity;
|
||||
|
||||
public sealed class UpdateCityCommandValidator : AbstractValidator<UpdateCityCommand>
|
||||
{
|
||||
public UpdateCityCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateCity;
|
||||
|
||||
/// <summary>Admin: edit a city's names/sort order. <c>Id</c> comes from the route. Invalidates cache.</summary>
|
||||
public record UpdateCityCommand(long Id, string NameFa, string NameEn, int SortOrder)
|
||||
: IRequest<OperationResult<CityDto>>;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateDistrict;
|
||||
|
||||
internal sealed class UpdateDistrictCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<UpdateDistrictCommand, OperationResult<DistrictDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<DistrictDto>> Handle(UpdateDistrictCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var district = await unitOfWork.GeoRepository.GetDistrictAsync(request.Id, cancellationToken);
|
||||
if (district is null)
|
||||
return OperationResult<DistrictDto>.NotFoundResult("District not found.");
|
||||
|
||||
district.NameFa = request.NameFa;
|
||||
district.NameEn = request.NameEn;
|
||||
district.SortOrder = request.SortOrder;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<DistrictDto>.SuccessResult(
|
||||
new DistrictDto(district.Id, district.CityId, district.NameFa, district.NameEn, district.SortOrder));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateDistrict;
|
||||
|
||||
public sealed class UpdateDistrictCommandValidator : AbstractValidator<UpdateDistrictCommand>
|
||||
{
|
||||
public UpdateDistrictCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateDistrict;
|
||||
|
||||
/// <summary>Admin: edit a district's names/sort order. <c>Id</c> comes from the route. Invalidates cache.</summary>
|
||||
public record UpdateDistrictCommand(long Id, string NameFa, string NameEn, int SortOrder)
|
||||
: IRequest<OperationResult<DistrictDto>>;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateProvince;
|
||||
|
||||
internal sealed class UpdateProvinceCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<UpdateProvinceCommand, OperationResult<ProvinceDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<ProvinceDto>> Handle(UpdateProvinceCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var province = await unitOfWork.GeoRepository.GetProvinceAsync(request.Id, cancellationToken);
|
||||
if (province is null)
|
||||
return OperationResult<ProvinceDto>.NotFoundResult("Province not found.");
|
||||
|
||||
province.NameFa = request.NameFa;
|
||||
province.NameEn = request.NameEn;
|
||||
province.SortOrder = request.SortOrder;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
await GeoCache.InvalidateAsync(cache, cancellationToken);
|
||||
|
||||
return OperationResult<ProvinceDto>.SuccessResult(
|
||||
new ProvinceDto(province.Id, province.NameFa, province.NameEn, province.SortOrder));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateProvince;
|
||||
|
||||
public sealed class UpdateProvinceCommandValidator : AbstractValidator<UpdateProvinceCommand>
|
||||
{
|
||||
public UpdateProvinceCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Commands.UpdateProvince;
|
||||
|
||||
/// <summary>Admin: edit a province's names/sort order. <c>Id</c> comes from the route. Invalidates cache.</summary>
|
||||
public record UpdateProvinceCommand(long Id, string NameFa, string NameEn, int SortOrder)
|
||||
: IRequest<OperationResult<ProvinceDto>>;
|
||||
@@ -0,0 +1,31 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
|
||||
namespace Baya.Application.Features.Geography;
|
||||
|
||||
/// <summary>
|
||||
/// Cache-key scheme for the read-heavy geo lookups. Every data key is namespaced by a generation token;
|
||||
/// an admin write bumps the token, which orphans all prior geo entries in one move (they lapse by TTL).
|
||||
/// This makes cascade invalidation trivial and correct — deactivating a province instantly hides its
|
||||
/// cities/districts without having to enumerate and evict each child key.
|
||||
/// </summary>
|
||||
internal static class GeoCache
|
||||
{
|
||||
private const string VersionKey = "geo:version";
|
||||
|
||||
// Reference data changes rarely; a modest TTL bounds staleness even if a bump is ever missed.
|
||||
public static readonly TimeSpan Ttl = TimeSpan.FromHours(1);
|
||||
|
||||
public static ValueTask<string> VersionAsync(ICacheService cache, CancellationToken cancellationToken)
|
||||
=> cache.GetOrCreateAsync(VersionKey, _ => ValueTask.FromResult(NewToken()), null, cancellationToken);
|
||||
|
||||
public static ValueTask InvalidateAsync(ICacheService cache, CancellationToken cancellationToken)
|
||||
=> cache.SetAsync(VersionKey, NewToken(), null, cancellationToken);
|
||||
|
||||
public static string ProvincesKey(string version) => $"geo:{version}:provinces";
|
||||
public static string CitiesKey(string version, long provinceId) => $"geo:{version}:cities:{provinceId}";
|
||||
public static string DistrictsKey(string version, long cityId) => $"geo:{version}:districts:{cityId}";
|
||||
public static string TreeKey(string version) => $"geo:{version}:tree";
|
||||
|
||||
private static string NewToken() => Guid.NewGuid().ToString("N");
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Queries.GetGeoTree;
|
||||
|
||||
internal sealed class GetGeoTreeQueryHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<GetGeoTreeQuery, OperationResult<IReadOnlyList<ProvinceTreeDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<IReadOnlyList<ProvinceTreeDto>>> Handle(GetGeoTreeQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var version = await GeoCache.VersionAsync(cache, cancellationToken);
|
||||
|
||||
var tree = await cache.GetOrCreateAsync(
|
||||
GeoCache.TreeKey(version),
|
||||
async ct => await unitOfWork.GeoRepository.GetActiveTreeAsync(ct),
|
||||
GeoCache.Ttl,
|
||||
cancellationToken);
|
||||
|
||||
return OperationResult<IReadOnlyList<ProvinceTreeDto>>.SuccessResult(tree);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Queries.GetGeoTree;
|
||||
|
||||
/// <summary>The full active province→city→district tree in one cached payload for a single-round-trip
|
||||
/// cascading dropdown. Public.</summary>
|
||||
public record GetGeoTreeQuery : IRequest<OperationResult<IReadOnlyList<ProvinceTreeDto>>>;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Queries.ListCities;
|
||||
|
||||
internal sealed class ListCitiesQueryHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<ListCitiesQuery, OperationResult<IReadOnlyList<CityDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<IReadOnlyList<CityDto>>> Handle(ListCitiesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var version = await GeoCache.VersionAsync(cache, cancellationToken);
|
||||
|
||||
var cities = await cache.GetOrCreateAsync(
|
||||
GeoCache.CitiesKey(version, request.ProvinceId),
|
||||
async ct => await unitOfWork.GeoRepository.ListActiveCitiesAsync(request.ProvinceId, ct),
|
||||
GeoCache.Ttl,
|
||||
cancellationToken);
|
||||
|
||||
return OperationResult<IReadOnlyList<CityDto>>.SuccessResult(cities);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Queries.ListCities;
|
||||
|
||||
/// <summary>Active cities under a province, ordered. Public.</summary>
|
||||
public record ListCitiesQuery(long ProvinceId) : IRequest<OperationResult<IReadOnlyList<CityDto>>>;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Queries.ListDistricts;
|
||||
|
||||
internal sealed class ListDistrictsQueryHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<ListDistrictsQuery, OperationResult<IReadOnlyList<DistrictDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<IReadOnlyList<DistrictDto>>> Handle(ListDistrictsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var version = await GeoCache.VersionAsync(cache, cancellationToken);
|
||||
|
||||
var districts = await cache.GetOrCreateAsync(
|
||||
GeoCache.DistrictsKey(version, request.CityId),
|
||||
async ct => await unitOfWork.GeoRepository.ListActiveDistrictsAsync(request.CityId, ct),
|
||||
GeoCache.Ttl,
|
||||
cancellationToken);
|
||||
|
||||
return OperationResult<IReadOnlyList<DistrictDto>>.SuccessResult(districts);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Queries.ListDistricts;
|
||||
|
||||
/// <summary>Active districts under a city, ordered. An empty list is valid — the city is whole-city-only
|
||||
/// and the caller selects whole-city coverage. Public.</summary>
|
||||
public record ListDistrictsQuery(long CityId) : IRequest<OperationResult<IReadOnlyList<DistrictDto>>>;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Queries.ListProvinces;
|
||||
|
||||
internal sealed class ListProvincesQueryHandler(IUnitOfWork unitOfWork, ICacheService cache)
|
||||
: IRequestHandler<ListProvincesQuery, OperationResult<IReadOnlyList<ProvinceDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<IReadOnlyList<ProvinceDto>>> Handle(ListProvincesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var version = await GeoCache.VersionAsync(cache, cancellationToken);
|
||||
|
||||
var provinces = await cache.GetOrCreateAsync(
|
||||
GeoCache.ProvincesKey(version),
|
||||
async ct => await unitOfWork.GeoRepository.ListActiveProvincesAsync(ct),
|
||||
GeoCache.Ttl,
|
||||
cancellationToken);
|
||||
|
||||
return OperationResult<IReadOnlyList<ProvinceDto>>.SuccessResult(provinces);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Geography.Queries.ListProvinces;
|
||||
|
||||
/// <summary>Active provinces, ordered by sort order, for the top of the cascading dropdown. Public.</summary>
|
||||
public record ListProvincesQuery : IRequest<OperationResult<IReadOnlyList<ProvinceDto>>>;
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea;
|
||||
|
||||
internal sealed class AddNurseServiceAreaCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<AddNurseServiceAreaCommand, OperationResult<NurseServiceAreaDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<NurseServiceAreaDto>> Handle(AddNurseServiceAreaCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<NurseServiceAreaDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<NurseServiceAreaDto>.ForbiddenResult("Only a nurse can declare a service area.");
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<NurseServiceAreaDto>.FailureResult("No nurse profile exists yet. Create your profile first.");
|
||||
|
||||
var city = await unitOfWork.GeoRepository.GetCityAsync(request.CityId, cancellationToken);
|
||||
if (city is null || !await unitOfWork.GeoRepository.IsCityActiveAsync(request.CityId, cancellationToken))
|
||||
return OperationResult<NurseServiceAreaDto>.FailureResult(nameof(request.CityId), "City not found or inactive.");
|
||||
|
||||
District? district = null;
|
||||
if (request.DistrictId is { } districtId)
|
||||
{
|
||||
if (!await unitOfWork.GeoRepository.IsDistrictInActiveCityAsync(districtId, request.CityId, cancellationToken))
|
||||
return OperationResult<NurseServiceAreaDto>.FailureResult(nameof(request.DistrictId), "District not found in this city, or inactive.");
|
||||
|
||||
district = await unitOfWork.GeoRepository.GetDistrictAsync(districtId, cancellationToken);
|
||||
}
|
||||
|
||||
// Whole-city and city+district duplicates are both rejected — the pre-check returns a clean 409;
|
||||
// the filtered unique-index pair is the DB backstop.
|
||||
if (await unitOfWork.NurseServiceAreaRepository.DuplicateExistsAsync(nid, request.CityId, request.DistrictId, cancellationToken))
|
||||
return OperationResult<NurseServiceAreaDto>.ConflictResult(
|
||||
request.DistrictId is null
|
||||
? "You already cover this whole city."
|
||||
: "You already cover this district.");
|
||||
|
||||
var area = new NurseServiceArea
|
||||
{
|
||||
NurseId = nid,
|
||||
CityId = request.CityId,
|
||||
DistrictId = request.DistrictId,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
// DEFERRED (b7): this is the write that later fans out nurse_search_index rows. Keep it the single
|
||||
// trigger point — do not build the index here.
|
||||
await unitOfWork.NurseServiceAreaRepository.AddAsync(area, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<NurseServiceAreaDto>.SuccessResult(new NurseServiceAreaDto(
|
||||
area.Id,
|
||||
city.Id,
|
||||
city.NameFa,
|
||||
city.NameEn,
|
||||
area.DistrictId,
|
||||
district?.NameFa,
|
||||
district?.NameEn,
|
||||
area.DistrictId is null,
|
||||
area.IsActive));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea;
|
||||
|
||||
public sealed class AddNurseServiceAreaCommandValidator : AbstractValidator<AddNurseServiceAreaCommand>
|
||||
{
|
||||
public AddNurseServiceAreaCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.CityId).GreaterThan(0);
|
||||
RuleFor(x => x.DistrictId).GreaterThan(0).When(x => x.DistrictId.HasValue);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea;
|
||||
|
||||
/// <summary>
|
||||
/// The signed-in nurse declares coverage. Omitting <see cref="DistrictId"/> (null) means the <b>whole
|
||||
/// city</b> — a deliberate coverage choice. The nurse is derived from the caller, never the body. A
|
||||
/// duplicate (including a duplicate whole-city row) returns 409.
|
||||
/// </summary>
|
||||
public record AddNurseServiceAreaCommand(long CityId, long? DistrictId)
|
||||
: IRequest<OperationResult<NurseServiceAreaDto>>;
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.ServiceAreas.Commands.RemoveNurseServiceArea;
|
||||
|
||||
internal sealed class RemoveNurseServiceAreaCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider clock)
|
||||
: IRequestHandler<RemoveNurseServiceAreaCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(RemoveNurseServiceAreaCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<bool>.ForbiddenResult("Only a nurse can manage service areas.");
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<bool>.NotFoundResult("Service area not found.");
|
||||
|
||||
// Tenancy: a non-owned/nonexistent id resolves to null → not-found (existence is not leaked).
|
||||
var area = await unitOfWork.NurseServiceAreaRepository.GetOwnedAsync(request.Id, nid, cancellationToken);
|
||||
if (area is null)
|
||||
return OperationResult<bool>.NotFoundResult("Service area not found.");
|
||||
|
||||
// DEFERRED (b7): triggers nurse_search_index row removal — keep this the single trigger point.
|
||||
area.DeletedAt = clock.UtcNow;
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.ServiceAreas.Commands.RemoveNurseServiceArea;
|
||||
|
||||
/// <summary>Soft-removes one of the signed-in nurse's own service areas (tenancy-checked).</summary>
|
||||
public record RemoveNurseServiceAreaCommand(long Id) : IRequest<OperationResult<bool>>;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.ServiceAreas.Queries.ListMyServiceAreas;
|
||||
|
||||
internal sealed class ListMyServiceAreasQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<ListMyServiceAreasQuery, OperationResult<PagedResult<NurseServiceAreaDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<NurseServiceAreaDto>>> Handle(ListMyServiceAreasQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<PagedResult<NurseServiceAreaDto>>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<PagedResult<NurseServiceAreaDto>>.ForbiddenResult("Only a nurse can view service areas.");
|
||||
|
||||
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<PagedResult<NurseServiceAreaDto>>.SuccessResult(
|
||||
new PagedResult<NurseServiceAreaDto>([], 0, page, pageSize));
|
||||
|
||||
var result = await unitOfWork.NurseServiceAreaRepository.ListAsync(nid, page, pageSize, cancellationToken);
|
||||
return OperationResult<PagedResult<NurseServiceAreaDto>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.ServiceAreas.Queries.ListMyServiceAreas;
|
||||
|
||||
/// <summary>The signed-in nurse's own service areas (tenancy-scoped, paginated, whole-city first).</summary>
|
||||
public record ListMyServiceAreasQuery(int Page = 1, int PageSize = 50)
|
||||
: IRequest<OperationResult<PagedResult<NurseServiceAreaDto>>>;
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Baya.Application.Models.Addresses;
|
||||
|
||||
/// <summary>
|
||||
/// A customer's saved address, returned only in the owner's own read path — the encrypted street address,
|
||||
/// postal code and recipient contact are decrypted here for the owner (they are never surfaced to any
|
||||
/// other actor in this phase). Coordinates are <see cref="decimal"/> and may be null when the address was
|
||||
/// saved without a confident geocode.
|
||||
/// </summary>
|
||||
public record CustomerAddressDto(
|
||||
long Id,
|
||||
string Title,
|
||||
long CityId,
|
||||
string CityNameFa,
|
||||
string CityNameEn,
|
||||
long? DistrictId,
|
||||
string DistrictNameFa,
|
||||
string DistrictNameEn,
|
||||
string AddressLine,
|
||||
string PostalCode,
|
||||
decimal? Latitude,
|
||||
decimal? Longitude,
|
||||
bool IsPrimary,
|
||||
string RecipientName,
|
||||
string RecipientPhone);
|
||||
@@ -26,6 +26,9 @@ public enum ApiResultStatusCode
|
||||
[Display(Name = "Authorization Error")]
|
||||
Forbidden = 403,
|
||||
|
||||
[Display(Name = "Conflict")]
|
||||
Conflict = 409,
|
||||
|
||||
[Display(Name = "Not Acceptable")]
|
||||
NotAcceptable = 406,
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ public class OperationResult<TResult> : IOperationResult
|
||||
/// <summary>Maps to HTTP 403 — e.g. self-assigning an internal admin role (backend-phase-2).</summary>
|
||||
public bool IsForbidden { get; set; }
|
||||
|
||||
/// <summary>Maps to HTTP 409 — a uniqueness/state conflict, e.g. a duplicate nurse service area
|
||||
/// (backend-phase-4). Distinct from a validation 400 so callers can react to the collision.</summary>
|
||||
public bool IsConflict { get; set; }
|
||||
|
||||
public static OperationResult<TResult> SuccessResult(TResult result)
|
||||
{
|
||||
return new OperationResult<TResult> { Result = result, IsSuccess = true };
|
||||
@@ -74,6 +78,15 @@ public class OperationResult<TResult> : IOperationResult
|
||||
return operationResult;
|
||||
}
|
||||
|
||||
public static OperationResult<TResult> ConflictResult(string message)
|
||||
{
|
||||
var operationResult = new OperationResult<TResult> { IsSuccess = false, IsConflict = true };
|
||||
|
||||
operationResult.ErrorMessages.Add(new("GeneralError", message));
|
||||
|
||||
return operationResult;
|
||||
}
|
||||
|
||||
public void AddError(string propertyName, string message)
|
||||
{
|
||||
IsSuccess = false;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Baya.Application.Models.Geography;
|
||||
|
||||
/// <summary>A city option under a province for the cascading dropdown.</summary>
|
||||
public record CityDto(long Id, long ProvinceId, string NameFa, string NameEn, int SortOrder);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Baya.Application.Models.Geography;
|
||||
|
||||
/// <summary>A city node in the geo tree, carrying its active districts (empty when the city has none).</summary>
|
||||
public record CityTreeDto(
|
||||
long Id,
|
||||
string NameFa,
|
||||
string NameEn,
|
||||
int SortOrder,
|
||||
IReadOnlyList<DistrictDto> Districts);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Baya.Application.Models.Geography;
|
||||
|
||||
/// <summary>A district option under a city. An empty district list for a city is a valid result — the
|
||||
/// caller then selects whole-city coverage.</summary>
|
||||
public record DistrictDto(long Id, long CityId, string NameFa, string NameEn, int SortOrder);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Baya.Application.Models.Geography;
|
||||
|
||||
/// <summary>
|
||||
/// A nurse's declared coverage row. <see cref="IsWholeCity"/> is <c>true</c> (and the district fields are
|
||||
/// null) when <c>district_id</c> is NULL — meaning the entire city, a deliberate coverage choice.
|
||||
/// </summary>
|
||||
public record NurseServiceAreaDto(
|
||||
long Id,
|
||||
long CityId,
|
||||
string CityNameFa,
|
||||
string CityNameEn,
|
||||
long? DistrictId,
|
||||
string DistrictNameFa,
|
||||
string DistrictNameEn,
|
||||
bool IsWholeCity,
|
||||
bool IsActive);
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Baya.Application.Models.Geography;
|
||||
|
||||
/// <summary>A province option for the cascading dropdown (active provinces, ordered by sort order).</summary>
|
||||
public record ProvinceDto(long Id, string NameFa, string NameEn, int SortOrder);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Baya.Application.Models.Geography;
|
||||
|
||||
/// <summary>A province node in the geo tree — the full active province→city→district hierarchy served in
|
||||
/// one cached payload for the cascading dropdown.</summary>
|
||||
public record ProvinceTreeDto(
|
||||
long Id,
|
||||
string NameFa,
|
||||
string NameEn,
|
||||
int SortOrder,
|
||||
IReadOnlyList<CityTreeDto> Cities);
|
||||
@@ -0,0 +1,23 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Geography;
|
||||
|
||||
/// <summary>
|
||||
/// A city under a <see cref="Province"/> — the main address/search granularity. A nurse who declares a
|
||||
/// city with no district covers the whole city; search intersects on cities and districts, never on a GPS
|
||||
/// radius.
|
||||
/// </summary>
|
||||
public class City : BaseEntity<long>
|
||||
{
|
||||
public long ProvinceId { get; set; }
|
||||
public Province Province { get; set; }
|
||||
|
||||
public string NameFa { get; set; }
|
||||
public string NameEn { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
public ICollection<District> Districts { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Geography;
|
||||
|
||||
/// <summary>
|
||||
/// An optional subdivision of a <see cref="City"/> — Tehran's 22 municipal مناطق, or major neighborhoods
|
||||
/// elsewhere. Optional by design: a city with no districts is a valid whole-city-only region, and adding
|
||||
/// neighborhoods elsewhere is a later admin insert, never a deploy.
|
||||
/// </summary>
|
||||
public class District : BaseEntity<long>
|
||||
{
|
||||
public long CityId { get; set; }
|
||||
public City City { get; set; }
|
||||
|
||||
public string NameFa { get; set; }
|
||||
public string NameEn { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Geography;
|
||||
|
||||
/// <summary>
|
||||
/// Where a nurse will travel — the membership row search later intersects with a customer's address.
|
||||
/// A row with <see cref="DistrictId"/> == <c>null</c> is a <b>meaningful "entire city"</b> coverage
|
||||
/// choice, not missing data; search treats it as matching every district in that city. Whole-city and
|
||||
/// city+district duplicates are both rejected at the DB level (see the filtered-index pair in the EF
|
||||
/// configuration) — a nurse cannot declare the same coverage twice.
|
||||
/// </summary>
|
||||
public class NurseServiceArea : BaseEntity<long>
|
||||
{
|
||||
public long NurseId { get; set; }
|
||||
|
||||
public long CityId { get; set; }
|
||||
public City City { get; set; }
|
||||
|
||||
/// <summary>NULL = the entire city (a deliberate coverage choice, not a forgotten selection).</summary>
|
||||
public long? DistrictId { get; set; }
|
||||
public District District { get; set; }
|
||||
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Geography;
|
||||
|
||||
/// <summary>
|
||||
/// Top of the geo hierarchy (Iran's 31 استان). Stored as a table — not a static list — so a new region
|
||||
/// launches with an admin insert, and <see cref="SortOrder"/>/<see cref="IsActive"/> drive ordered,
|
||||
/// toggleable cascading dropdowns. Deactivated far more often than deleted, so a toggled-off province
|
||||
/// vanishes from public dropdowns without orphaning the addresses/service areas under it.
|
||||
/// </summary>
|
||||
public class Province : BaseEntity<long>
|
||||
{
|
||||
public string NameFa { get; set; }
|
||||
public string NameEn { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
public ICollection<City> Cities { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Baya.Domain.Common;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
|
||||
namespace Baya.Domain.Entities.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// A customer's saved service location. The street address and recipient contact are encrypted PII at
|
||||
/// rest (decrypted only in the owner's own read path); <see cref="Latitude"/>/<see cref="Longitude"/> are
|
||||
/// produced by the <c>IGeocoder</c> seam and later consumed by the EVV distance check (b9). Exactly one
|
||||
/// address per customer is primary — enforced in the handler and by a filtered unique index.
|
||||
/// </summary>
|
||||
public class CustomerAddress : BaseEntity<long>
|
||||
{
|
||||
public long CustomerId { get; set; }
|
||||
public CustomerProfile Customer { get; set; }
|
||||
|
||||
public long CityId { get; set; }
|
||||
public City City { get; set; }
|
||||
|
||||
public long? DistrictId { get; set; }
|
||||
public District District { get; set; }
|
||||
|
||||
/// <summary>A label ("خانه"/"محل کار") — not PII, stored plaintext.</summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>Encrypted at rest.</summary>
|
||||
public string AddressLine { get; set; }
|
||||
|
||||
/// <summary>Encrypted at rest.</summary>
|
||||
public string PostalCode { get; set; }
|
||||
|
||||
public decimal? Latitude { get; set; }
|
||||
public decimal? Longitude { get; set; }
|
||||
|
||||
public bool IsPrimary { get; set; }
|
||||
|
||||
/// <summary>Encrypted at rest.</summary>
|
||||
public string RecipientName { get; set; }
|
||||
|
||||
/// <summary>Encrypted at rest.</summary>
|
||||
public string RecipientPhone { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+4
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+30
@@ -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());
|
||||
}
|
||||
}
|
||||
+29
@@ -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());
|
||||
}
|
||||
}
|
||||
+109
@@ -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']));
|
||||
}
|
||||
+48
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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());
|
||||
}
|
||||
}
|
||||
+46
@@ -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);
|
||||
}
|
||||
}
|
||||
+2516
File diff suppressed because it is too large
Load Diff
+391
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1183
File diff suppressed because it is too large
Load Diff
+6
@@ -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()
|
||||
|
||||
+78
@@ -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);
|
||||
}
|
||||
}
|
||||
+121
@@ -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);
|
||||
}
|
||||
+52
@@ -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());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user