backend phase 4: geography, addresses & nurse service areas
Adds the province -> city -> district reference hierarchy (geo schema, seeded with 31 provinces + capital cities + Tehran's 22 districts), nurse service areas (district_id NULL = whole city, filtered-index-pair uniqueness -> 409), and encrypted, geocoded customer addresses with a single-primary invariant. Introduces the IGeocoder seam (mocked) and 409 Conflict on the result envelope. Public cascading lookups are cached behind a generation-token scheme with invalidate-on-admin-write. One EF migration (GeographyAddressesServiceAreas, applied). Contract + swagger snapshot + handoff/report/registry updated. 103 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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": "*",
|
||||
|
||||
Reference in New Issue
Block a user