backend phase 4: geography, addresses & nurse service areas

Adds the province -> city -> district reference hierarchy (geo schema,
seeded with 31 provinces + capital cities + Tehran's 22 districts),
nurse service areas (district_id NULL = whole city, filtered-index-pair
uniqueness -> 409), and encrypted, geocoded customer addresses with a
single-primary invariant. Introduces the IGeocoder seam (mocked) and
409 Conflict on the result envelope. Public cascading lookups are cached
behind a generation-token scheme with invalidate-on-admin-write.

One EF migration (GeographyAddressesServiceAreas, applied). Contract +
swagger snapshot + handoff/report/registry updated. 103 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-02 16:06:12 +03:30
parent 39a979b1a7
commit 82561c4cc6
113 changed files with 9817 additions and 5 deletions
@@ -0,0 +1,87 @@
#nullable enable
using System.Text;
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Deterministic, network-free <see cref="IGeocoder"/> — the mock seam. It resolves an address to a
/// stable point jittered around the known city centroid (an unknown city falls back to Iran's centroid),
/// so the same address always yields the same coordinates without any external call. A configured global
/// switch or a per-address marker forces the null-coordinate path so the "saved without a map pin" state
/// is testable. The real implementation swaps to a Neshan/Google geocoding client behind this contract.
/// </summary>
public sealed class MockGeocoder(IOptions<SeamOptions> options) : IGeocoder
{
private readonly GeocodingOptions _options = options.Value.Geocoding;
// A few real city centroids for plausible pins; everything else falls back to Iran's centroid.
private static readonly IReadOnlyDictionary<string, (decimal Lat, decimal Lng)> Centroids =
new Dictionary<string, (decimal, decimal)>(StringComparer.OrdinalIgnoreCase)
{
["Tehran"] = (35.6892m, 51.3890m),
["Karaj"] = (35.8400m, 50.9391m),
["Mashhad"] = (36.2605m, 59.6168m),
["Isfahan"] = (32.6539m, 51.6660m),
["Shiraz"] = (29.5918m, 52.5837m),
["Tabriz"] = (38.0800m, 46.2919m),
["Ahvaz"] = (31.3183m, 48.6706m),
["Qom"] = (34.6416m, 50.8746m),
};
private static readonly (decimal Lat, decimal Lng) IranCentroid = (32.4279m, 53.6880m);
public ValueTask<GeocodeResult> GeocodeAsync(
string addressText,
string cityName,
string? districtName,
CancellationToken cancellationToken = default)
{
var formatted = FormatAddress(addressText, cityName, districtName);
var unresolved =
_options.ReturnNullCoordinates ||
(!string.IsNullOrEmpty(_options.LowConfidenceMarker) &&
addressText is not null &&
addressText.Contains(_options.LowConfidenceMarker, StringComparison.OrdinalIgnoreCase));
if (unresolved)
return ValueTask.FromResult(new GeocodeResult(null, null, formatted, 0.2));
var centroid = Centroids.TryGetValue(cityName ?? string.Empty, out var c) ? c : IranCentroid;
// Deterministic ±~0.045° jitter (~5 km) derived from a stable FNV-1a hash of the full text —
// never string.GetHashCode(), which is randomized per process and would break test determinism.
var seed = StableHash($"{cityName}|{districtName}|{addressText}");
var latOffset = Offset(seed);
var lngOffset = Offset(seed >> 16 ^ seed);
var lat = decimal.Round(centroid.Lat + latOffset, 6);
var lng = decimal.Round(centroid.Lng + lngOffset, 6);
return ValueTask.FromResult(new GeocodeResult(lat, lng, formatted, _options.ResolvedConfidence));
}
private static string FormatAddress(string addressText, string cityName, string? districtName) =>
string.Join("، ", new[] { cityName, districtName, addressText }
.Where(part => !string.IsNullOrWhiteSpace(part)));
// Maps the low 16 bits of the seed to a signed offset in [-0.045, +0.045] degrees.
private static decimal Offset(uint seed) => ((seed & 0xFFFF) / 65535m - 0.5m) * 0.09m;
private static uint StableHash(string value)
{
const uint offsetBasis = 2166136261;
const uint prime = 16777619;
var hash = offsetBasis;
foreach (var b in Encoding.UTF8.GetBytes(value))
{
hash ^= b;
hash *= prime;
}
return hash;
}
}
@@ -11,6 +11,25 @@ public sealed class SeamOptions
public FieldEncryptionOptions FieldEncryption { get; set; } = new();
public ObjectStorageOptions ObjectStorage { get; set; } = new();
public BankOwnershipOptions BankOwnership { get; set; } = new();
public GeocodingOptions Geocoding { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>IGeocoder</c>. By default it resolves every address to a deterministic point around
/// the city centroid. Set <see cref="ReturnNullCoordinates"/> to force the unresolved path globally, or
/// embed <see cref="LowConfidenceMarker"/> in a single address to exercise the "saved without a map pin"
/// UI state per-request. The real vendor implementation ignores these.
/// </summary>
public sealed class GeocodingOptions
{
/// <summary>When true, every geocode returns null coordinates with low confidence.</summary>
public bool ReturnNullCoordinates { get; set; }
/// <summary>An address whose text contains this marker resolves to null coordinates (testability).</summary>
public string LowConfidenceMarker { get; set; } = "NO_GEO";
/// <summary>Confidence returned for a successfully resolved address.</summary>
public double ResolvedConfidence { get; set; } = 0.9;
}
/// <summary>
@@ -32,6 +32,10 @@ public static class ServiceCollectionExtension
// fake match; a real Finnotech/banking-bridge client replaces this registration only.
services.AddSingleton<IBankAccountOwnershipVerifier, MockBankAccountOwnershipVerifier>();
// Address geocoding (backend-phase-4). The mock derives deterministic coordinates around the city
// centroid with no network call; a real Neshan/Google geocoding client replaces this registration.
services.AddSingleton<IGeocoder, MockGeocoder>();
return services;
}
}
@@ -121,5 +121,15 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
builder.Property(a => a.AccountHolderName).HasConversion(encrypted);
builder.Property(a => a.Iban).HasConversion(encrypted);
});
// b4 address PII: street line, postal code and recipient contact are encrypted at rest through
// the same seam; the title label and coordinates are not PII and stay plaintext.
modelBuilder.Entity<CustomerAddress>(builder =>
{
builder.Property(a => a.AddressLine).HasConversion(encrypted);
builder.Property(a => a.PostalCode).HasConversion(encrypted);
builder.Property(a => a.RecipientName).HasConversion(encrypted);
builder.Property(a => a.RecipientPhone).HasConversion(encrypted);
});
}
}
@@ -0,0 +1,30 @@
using Baya.Domain.Entities.Geography;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
internal sealed class CityConfig : IEntityTypeConfiguration<City>
{
public void Configure(EntityTypeBuilder<City> builder)
{
builder.ToTable("Cities", "geo");
builder.Property(c => c.NameFa).HasMaxLength(150).IsRequired();
builder.Property(c => c.NameEn).HasMaxLength(150).IsRequired();
builder.Property(c => c.SortOrder).HasDefaultValue(0);
builder.Property(c => c.IsActive).HasDefaultValue(true);
// Ordered cascading lookup: cities for a province in sort order.
builder.HasIndex(c => new { c.ProvinceId, c.SortOrder });
builder.HasOne(c => c.Province)
.WithMany(p => p.Cities)
.HasForeignKey(c => c.ProvinceId)
.IsRequired();
builder.HasQueryFilter(c => c.DeletedAt == null);
builder.HasData(GeographySeed.Cities());
}
}
@@ -0,0 +1,29 @@
using Baya.Domain.Entities.Geography;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
internal sealed class DistrictConfig : IEntityTypeConfiguration<District>
{
public void Configure(EntityTypeBuilder<District> builder)
{
builder.ToTable("Districts", "geo");
builder.Property(d => d.NameFa).HasMaxLength(150).IsRequired();
builder.Property(d => d.NameEn).HasMaxLength(150).IsRequired();
builder.Property(d => d.SortOrder).HasDefaultValue(0);
builder.Property(d => d.IsActive).HasDefaultValue(true);
builder.HasIndex(d => new { d.CityId, d.SortOrder });
builder.HasOne(d => d.City)
.WithMany(c => c.Districts)
.HasForeignKey(d => d.CityId)
.IsRequired();
builder.HasQueryFilter(d => d.DeletedAt == null);
builder.HasData(GeographySeed.Districts());
}
}
@@ -0,0 +1,109 @@
namespace Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
/// <summary>
/// The one-time province/city/district seed, loaded via <c>HasData</c> so the rows land with the
/// migration on a fresh DB (the b1 seeding path). Ids are fixed and deterministic — city id is
/// <c>100 + provinceId</c>, Tehran's districts are <c>1001…1022</c> — so re-running is idempotent and the
/// model snapshot stays stable. All 31 provinces get their capital city (which covers the product's
/// white-space targets — Tehran, Karaj, Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom, all provincial
/// capitals); only Tehran gets districts at seed time. Adding neighborhoods elsewhere is a later admin
/// insert, never a deploy.
/// </summary>
internal static class GeographySeed
{
public const long TehranProvinceId = 1;
public const long TehranCityId = 101;
// (id, name_fa, name_en, capital_fa, capital_en). Tehran first, then by convention; sort_order = id.
private static readonly (long Id, string NameFa, string NameEn, string CapitalFa, string CapitalEn)[] ProvinceRows =
[
(1, "تهران", "Tehran", "تهران", "Tehran"),
(2, "البرز", "Alborz", "کرج", "Karaj"),
(3, "اصفهان", "Isfahan", "اصفهان", "Isfahan"),
(4, "فارس", "Fars", "شیراز", "Shiraz"),
(5, "خراسان رضوی", "Razavi Khorasan", "مشهد", "Mashhad"),
(6, "آذربایجان شرقی", "East Azerbaijan", "تبریز", "Tabriz"),
(7, "آذربایجان غربی", "West Azerbaijan", "ارومیه", "Urmia"),
(8, "خوزستان", "Khuzestan", "اهواز", "Ahvaz"),
(9, "قم", "Qom", "قم", "Qom"),
(10, "کرمان", "Kerman", "کرمان", "Kerman"),
(11, "گیلان", "Gilan", "رشت", "Rasht"),
(12, "مازندران", "Mazandaran", "ساری", "Sari"),
(13, "مرکزی", "Markazi", "اراک", "Arak"),
(14, "اردبیل", "Ardabil", "اردبیل", "Ardabil"),
(15, "قزوین", "Qazvin", "قزوین", "Qazvin"),
(16, "کرمانشاه", "Kermanshah", "کرمانشاه", "Kermanshah"),
(17, "خراسان شمالی", "North Khorasan", "بجنورد", "Bojnord"),
(18, "خراسان جنوبی", "South Khorasan", "بیرجند", "Birjand"),
(19, "همدان", "Hamadan", "همدان", "Hamadan"),
(20, "کردستان", "Kurdistan", "سنندج", "Sanandaj"),
(21, "لرستان", "Lorestan", "خرم‌آباد", "Khorramabad"),
(22, "گلستان", "Golestan", "گرگان", "Gorgan"),
(23, "هرمزگان", "Hormozgan", "بندرعباس", "Bandar Abbas"),
(24, "بوشهر", "Bushehr", "بوشهر", "Bushehr"),
(25, "زنجان", "Zanjan", "زنجان", "Zanjan"),
(26, "سمنان", "Semnan", "سمنان", "Semnan"),
(27, "یزد", "Yazd", "یزد", "Yazd"),
(28, "سیستان و بلوچستان", "Sistan and Baluchestan", "زاهدان", "Zahedan"),
(29, "چهارمحال و بختیاری", "Chaharmahal and Bakhtiari", "شهرکرد", "Shahrekord"),
(30, "کهگیلویه و بویراحمد", "Kohgiluyeh and Boyer-Ahmad", "یاسوج", "Yasuj"),
(31, "ایلام", "Ilam", "ایلام", "Ilam"),
];
// Tehran's 22 municipal مناطق, as Persian ordinals ("منطقه ۱" … "منطقه ۲۲").
private static readonly string[] PersianDigits = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
public static object[] Provinces()
{
var ts = SeedConstants.Timestamp;
return ProvinceRows
.Select(p => (object)new
{
p.Id,
p.NameFa,
p.NameEn,
SortOrder = (int)p.Id,
IsActive = true,
CreatedAt = ts
})
.ToArray();
}
public static object[] Cities()
{
var ts = SeedConstants.Timestamp;
return ProvinceRows
.Select(p => (object)new
{
Id = 100 + p.Id,
ProvinceId = p.Id,
NameFa = p.CapitalFa,
NameEn = p.CapitalEn,
SortOrder = 1,
IsActive = true,
CreatedAt = ts
})
.ToArray();
}
public static object[] Districts()
{
var ts = SeedConstants.Timestamp;
return Enumerable.Range(1, 22)
.Select(n => (object)new
{
Id = 1000L + n,
CityId = TehranCityId,
NameFa = $"منطقه {ToPersianNumber(n)}",
NameEn = $"District {n}",
SortOrder = n,
IsActive = true,
CreatedAt = ts
})
.ToArray();
}
private static string ToPersianNumber(int value) =>
string.Concat(value.ToString(System.Globalization.CultureInfo.InvariantCulture)
.Select(c => PersianDigits[c - '0']));
}
@@ -0,0 +1,48 @@
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
internal sealed class NurseServiceAreaConfig : IEntityTypeConfiguration<NurseServiceArea>
{
public void Configure(EntityTypeBuilder<NurseServiceArea> builder)
{
builder.ToTable("NurseServiceAreas", "geo");
builder.Property(a => a.IsActive).HasDefaultValue(true);
// UNIQUE(nurse_id, city_id, district_id) that correctly rejects a duplicate whole-city row.
// SQL Server treats NULLs as distinct, so a single unique index would wrongly allow two
// "whole city" rows for the same nurse+city. Split into a filtered pair: one enforces at most one
// whole-city row (district_id IS NULL), the other enforces uniqueness of city+district rows. Both
// exclude soft-deleted rows so a removed area can be re-declared.
builder.HasIndex(a => new { a.NurseId, a.CityId })
.IsUnique()
.HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL")
.HasDatabaseName("UX_NurseServiceAreas_Nurse_City_WholeCity");
builder.HasIndex(a => new { a.NurseId, a.CityId, a.DistrictId })
.IsUnique()
.HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL")
.HasDatabaseName("UX_NurseServiceAreas_Nurse_City_District");
builder.HasOne<NurseProfile>()
.WithMany()
.HasForeignKey(a => a.NurseId)
.IsRequired();
builder.HasOne(a => a.City)
.WithMany()
.HasForeignKey(a => a.CityId)
.IsRequired();
builder.HasOne(a => a.District)
.WithMany()
.HasForeignKey(a => a.DistrictId)
.IsRequired(false);
builder.HasQueryFilter(a => a.DeletedAt == null);
}
}
@@ -0,0 +1,24 @@
using Baya.Domain.Entities.Geography;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
internal sealed class ProvinceConfig : IEntityTypeConfiguration<Province>
{
public void Configure(EntityTypeBuilder<Province> builder)
{
builder.ToTable("Provinces", "geo");
builder.Property(p => p.NameFa).HasMaxLength(150).IsRequired();
builder.Property(p => p.NameEn).HasMaxLength(150).IsRequired();
builder.Property(p => p.SortOrder).HasDefaultValue(0);
builder.Property(p => p.IsActive).HasDefaultValue(true);
builder.HasIndex(p => p.SortOrder);
builder.HasQueryFilter(p => p.DeletedAt == null);
builder.HasData(GeographySeed.Provinces());
}
}
@@ -0,0 +1,46 @@
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.IdentityConfig;
internal sealed class CustomerAddressConfig : IEntityTypeConfiguration<CustomerAddress>
{
public void Configure(EntityTypeBuilder<CustomerAddress> builder)
{
builder.ToTable("CustomerAddresses", "usr");
builder.Property(a => a.Title).HasMaxLength(100).IsRequired();
// address_line, postal_code and recipient contact are encrypted at rest (converters wired in
// ApplicationDbContext). The title and coordinates are not PII and stay plaintext.
builder.Property(a => a.Latitude).HasPrecision(9, 6);
builder.Property(a => a.Longitude).HasPrecision(9, 6);
builder.Property(a => a.IsPrimary).HasDefaultValue(false);
// Exactly one primary address per customer — the authoritative DB backstop the set-primary
// transaction must never trip. Excludes soft-deleted rows.
builder.HasIndex(a => a.CustomerId)
.IsUnique()
.HasFilter("[IsPrimary] = 1 AND [DeletedAt] IS NULL")
.HasDatabaseName("UX_CustomerAddresses_Customer_Primary");
builder.HasOne(a => a.Customer)
.WithMany()
.HasForeignKey(a => a.CustomerId)
.IsRequired();
builder.HasOne(a => a.City)
.WithMany()
.HasForeignKey(a => a.CityId)
.IsRequired();
builder.HasOne(a => a.District)
.WithMany()
.HasForeignKey(a => a.DistrictId)
.IsRequired(false);
builder.HasQueryFilter(a => a.DeletedAt == null);
}
}
@@ -0,0 +1,391 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class GeographyAddressesServiceAreas : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "geo");
migrationBuilder.CreateTable(
name: "Provinces",
schema: "geo",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
NameFa = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
NameEn = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Provinces", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Cities",
schema: "geo",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ProvinceId = table.Column<long>(type: "bigint", nullable: false),
NameFa = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
NameEn = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Cities", x => x.Id);
table.ForeignKey(
name: "FK_Cities_Provinces_ProvinceId",
column: x => x.ProvinceId,
principalSchema: "geo",
principalTable: "Provinces",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Districts",
schema: "geo",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CityId = table.Column<long>(type: "bigint", nullable: false),
NameFa = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
NameEn = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Districts", x => x.Id);
table.ForeignKey(
name: "FK_Districts_Cities_CityId",
column: x => x.CityId,
principalSchema: "geo",
principalTable: "Cities",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "CustomerAddresses",
schema: "usr",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CustomerId = table.Column<long>(type: "bigint", nullable: false),
CityId = table.Column<long>(type: "bigint", nullable: false),
DistrictId = table.Column<long>(type: "bigint", nullable: true),
Title = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
AddressLine = table.Column<string>(type: "nvarchar(max)", nullable: true),
PostalCode = table.Column<string>(type: "nvarchar(max)", nullable: true),
Latitude = table.Column<decimal>(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true),
Longitude = table.Column<decimal>(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true),
IsPrimary = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
RecipientName = table.Column<string>(type: "nvarchar(max)", nullable: true),
RecipientPhone = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CustomerAddresses", x => x.Id);
table.ForeignKey(
name: "FK_CustomerAddresses_Cities_CityId",
column: x => x.CityId,
principalSchema: "geo",
principalTable: "Cities",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_CustomerAddresses_CustomerProfiles_CustomerId",
column: x => x.CustomerId,
principalSchema: "usr",
principalTable: "CustomerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_CustomerAddresses_Districts_DistrictId",
column: x => x.DistrictId,
principalSchema: "geo",
principalTable: "Districts",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "NurseServiceAreas",
schema: "geo",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
NurseId = table.Column<long>(type: "bigint", nullable: false),
CityId = table.Column<long>(type: "bigint", nullable: false),
DistrictId = table.Column<long>(type: "bigint", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NurseServiceAreas", x => x.Id);
table.ForeignKey(
name: "FK_NurseServiceAreas_Cities_CityId",
column: x => x.CityId,
principalSchema: "geo",
principalTable: "Cities",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NurseServiceAreas_Districts_DistrictId",
column: x => x.DistrictId,
principalSchema: "geo",
principalTable: "Districts",
principalColumn: "Id");
table.ForeignKey(
name: "FK_NurseServiceAreas_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.InsertData(
schema: "geo",
table: "Provinces",
columns: new[] { "Id", "CreatedAt", "CreatedById", "DeletedAt", "IsActive", "ModifiedAt", "ModifiedById", "NameEn", "NameFa", "SortOrder" },
values: new object[,]
{
{ 1L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Tehran", "تهران", 1 },
{ 2L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Alborz", "البرز", 2 },
{ 3L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Isfahan", "اصفهان", 3 },
{ 4L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Fars", "فارس", 4 },
{ 5L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Razavi Khorasan", "خراسان رضوی", 5 },
{ 6L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "East Azerbaijan", "آذربایجان شرقی", 6 },
{ 7L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "West Azerbaijan", "آذربایجان غربی", 7 },
{ 8L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Khuzestan", "خوزستان", 8 },
{ 9L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Qom", "قم", 9 },
{ 10L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kerman", "کرمان", 10 },
{ 11L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Gilan", "گیلان", 11 },
{ 12L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Mazandaran", "مازندران", 12 },
{ 13L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Markazi", "مرکزی", 13 },
{ 14L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Ardabil", "اردبیل", 14 },
{ 15L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Qazvin", "قزوین", 15 },
{ 16L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kermanshah", "کرمانشاه", 16 },
{ 17L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "North Khorasan", "خراسان شمالی", 17 },
{ 18L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "South Khorasan", "خراسان جنوبی", 18 },
{ 19L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Hamadan", "همدان", 19 },
{ 20L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kurdistan", "کردستان", 20 },
{ 21L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Lorestan", "لرستان", 21 },
{ 22L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Golestan", "گلستان", 22 },
{ 23L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Hormozgan", "هرمزگان", 23 },
{ 24L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Bushehr", "بوشهر", 24 },
{ 25L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Zanjan", "زنجان", 25 },
{ 26L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Semnan", "سمنان", 26 },
{ 27L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Yazd", "یزد", 27 },
{ 28L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Sistan and Baluchestan", "سیستان و بلوچستان", 28 },
{ 29L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Chaharmahal and Bakhtiari", "چهارمحال و بختیاری", 29 },
{ 30L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kohgiluyeh and Boyer-Ahmad", "کهگیلویه و بویراحمد", 30 },
{ 31L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Ilam", "ایلام", 31 }
});
migrationBuilder.InsertData(
schema: "geo",
table: "Cities",
columns: new[] { "Id", "CreatedAt", "CreatedById", "DeletedAt", "IsActive", "ModifiedAt", "ModifiedById", "NameEn", "NameFa", "ProvinceId", "SortOrder" },
values: new object[,]
{
{ 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Tehran", "تهران", 1L, 1 },
{ 102L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Karaj", "کرج", 2L, 1 },
{ 103L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Isfahan", "اصفهان", 3L, 1 },
{ 104L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Shiraz", "شیراز", 4L, 1 },
{ 105L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Mashhad", "مشهد", 5L, 1 },
{ 106L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Tabriz", "تبریز", 6L, 1 },
{ 107L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Urmia", "ارومیه", 7L, 1 },
{ 108L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Ahvaz", "اهواز", 8L, 1 },
{ 109L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Qom", "قم", 9L, 1 },
{ 110L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kerman", "کرمان", 10L, 1 },
{ 111L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Rasht", "رشت", 11L, 1 },
{ 112L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Sari", "ساری", 12L, 1 },
{ 113L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Arak", "اراک", 13L, 1 },
{ 114L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Ardabil", "اردبیل", 14L, 1 },
{ 115L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Qazvin", "قزوین", 15L, 1 },
{ 116L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kermanshah", "کرمانشاه", 16L, 1 },
{ 117L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Bojnord", "بجنورد", 17L, 1 },
{ 118L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Birjand", "بیرجند", 18L, 1 },
{ 119L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Hamadan", "همدان", 19L, 1 },
{ 120L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Sanandaj", "سنندج", 20L, 1 },
{ 121L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Khorramabad", "خرم‌آباد", 21L, 1 },
{ 122L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Gorgan", "گرگان", 22L, 1 },
{ 123L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Bandar Abbas", "بندرعباس", 23L, 1 },
{ 124L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Bushehr", "بوشهر", 24L, 1 },
{ 125L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Zanjan", "زنجان", 25L, 1 },
{ 126L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Semnan", "سمنان", 26L, 1 },
{ 127L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Yazd", "یزد", 27L, 1 },
{ 128L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Zahedan", "زاهدان", 28L, 1 },
{ 129L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Shahrekord", "شهرکرد", 29L, 1 },
{ 130L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Yasuj", "یاسوج", 30L, 1 },
{ 131L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Ilam", "ایلام", 31L, 1 }
});
migrationBuilder.InsertData(
schema: "geo",
table: "Districts",
columns: new[] { "Id", "CityId", "CreatedAt", "CreatedById", "DeletedAt", "IsActive", "ModifiedAt", "ModifiedById", "NameEn", "NameFa", "SortOrder" },
values: new object[,]
{
{ 1001L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 1", "منطقه ۱", 1 },
{ 1002L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 2", "منطقه ۲", 2 },
{ 1003L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 3", "منطقه ۳", 3 },
{ 1004L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 4", "منطقه ۴", 4 },
{ 1005L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 5", "منطقه ۵", 5 },
{ 1006L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 6", "منطقه ۶", 6 },
{ 1007L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 7", "منطقه ۷", 7 },
{ 1008L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 8", "منطقه ۸", 8 },
{ 1009L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 9", "منطقه ۹", 9 },
{ 1010L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 10", "منطقه ۱۰", 10 },
{ 1011L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 11", "منطقه ۱۱", 11 },
{ 1012L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 12", "منطقه ۱۲", 12 },
{ 1013L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 13", "منطقه ۱۳", 13 },
{ 1014L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 14", "منطقه ۱۴", 14 },
{ 1015L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 15", "منطقه ۱۵", 15 },
{ 1016L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 16", "منطقه ۱۶", 16 },
{ 1017L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 17", "منطقه ۱۷", 17 },
{ 1018L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 18", "منطقه ۱۸", 18 },
{ 1019L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 19", "منطقه ۱۹", 19 },
{ 1020L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 20", "منطقه ۲۰", 20 },
{ 1021L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 21", "منطقه ۲۱", 21 },
{ 1022L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 22", "منطقه ۲۲", 22 }
});
migrationBuilder.CreateIndex(
name: "IX_Cities_ProvinceId_SortOrder",
schema: "geo",
table: "Cities",
columns: new[] { "ProvinceId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_CustomerAddresses_CityId",
schema: "usr",
table: "CustomerAddresses",
column: "CityId");
migrationBuilder.CreateIndex(
name: "IX_CustomerAddresses_DistrictId",
schema: "usr",
table: "CustomerAddresses",
column: "DistrictId");
migrationBuilder.CreateIndex(
name: "UX_CustomerAddresses_Customer_Primary",
schema: "usr",
table: "CustomerAddresses",
column: "CustomerId",
unique: true,
filter: "[IsPrimary] = 1 AND [DeletedAt] IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Districts_CityId_SortOrder",
schema: "geo",
table: "Districts",
columns: new[] { "CityId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_NurseServiceAreas_CityId",
schema: "geo",
table: "NurseServiceAreas",
column: "CityId");
migrationBuilder.CreateIndex(
name: "IX_NurseServiceAreas_DistrictId",
schema: "geo",
table: "NurseServiceAreas",
column: "DistrictId");
migrationBuilder.CreateIndex(
name: "UX_NurseServiceAreas_Nurse_City_District",
schema: "geo",
table: "NurseServiceAreas",
columns: new[] { "NurseId", "CityId", "DistrictId" },
unique: true,
filter: "[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL");
migrationBuilder.CreateIndex(
name: "UX_NurseServiceAreas_Nurse_City_WholeCity",
schema: "geo",
table: "NurseServiceAreas",
columns: new[] { "NurseId", "CityId" },
unique: true,
filter: "[DistrictId] IS NULL AND [DeletedAt] IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Provinces_SortOrder",
schema: "geo",
table: "Provinces",
column: "SortOrder");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CustomerAddresses",
schema: "usr");
migrationBuilder.DropTable(
name: "NurseServiceAreas",
schema: "geo");
migrationBuilder.DropTable(
name: "Districts",
schema: "geo");
migrationBuilder.DropTable(
name: "Cities",
schema: "geo");
migrationBuilder.DropTable(
name: "Provinces",
schema: "geo");
}
}
}
@@ -13,6 +13,9 @@ public class UnitOfWork : IUnitOfWork
public ICustomerProfileRepository CustomerProfileRepository { get; }
public IPatientRepository PatientRepository { get; }
public INurseBankAccountRepository NurseBankAccountRepository { get; }
public IGeoRepository GeoRepository { get; }
public INurseServiceAreaRepository NurseServiceAreaRepository { get; }
public ICustomerAddressRepository CustomerAddressRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -24,6 +27,9 @@ public class UnitOfWork : IUnitOfWork
CustomerProfileRepository = new CustomerProfileRepository(_db);
PatientRepository = new PatientRepository(_db);
NurseBankAccountRepository = new NurseBankAccountRepository(_db);
GeoRepository = new GeoRepository(_db);
NurseServiceAreaRepository = new NurseServiceAreaRepository(_db);
CustomerAddressRepository = new CustomerAddressRepository(_db);
}
public Task CommitAsync()
@@ -0,0 +1,78 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Addresses;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Identity;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class CustomerAddressRepository : BaseAsyncRepository<CustomerAddress>, ICustomerAddressRepository
{
public CustomerAddressRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(CustomerAddress address, CancellationToken cancellationToken)
=> base.AddAsync(address);
public Task<CustomerAddress> GetOwnedAsync(long id, long customerId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(a => a.Id == id && a.CustomerId == customerId, cancellationToken);
public Task<bool> HasAnyAsync(long customerId, CancellationToken cancellationToken)
=> TableNoTracking.AnyAsync(a => a.CustomerId == customerId, cancellationToken);
public async Task ClearOtherPrimaryAsync(long customerId, long addressId, CancellationToken cancellationToken)
=> await Entities
.Where(a => a.CustomerId == customerId && a.IsPrimary && a.Id != addressId)
.ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsPrimary, false), cancellationToken);
public async Task SetPrimaryAsync(long customerId, long addressId, CancellationToken cancellationToken)
{
// Clear-then-set inside one transaction so the filtered unique index is never momentarily violated.
await using var transaction = await DbContext.Database.BeginTransactionAsync(cancellationToken);
await Entities
.Where(a => a.CustomerId == customerId && a.IsPrimary)
.ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsPrimary, false), cancellationToken);
await Entities
.Where(a => a.Id == addressId && a.CustomerId == customerId)
.ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsPrimary, true), cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
public async Task<PagedResult<CustomerAddressDto>> ListAsync(long customerId, int page, int pageSize, CancellationToken cancellationToken)
{
var query = TableNoTracking.Where(a => a.CustomerId == customerId);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(a => a.IsPrimary)
.ThenByDescending(a => a.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
// The encrypted columns are decrypted by the value converter on materialization — the owner's
// own read is the only path that returns the plaintext address.
.Select(a => new CustomerAddressDto(
a.Id,
a.Title,
a.CityId,
a.City.NameFa,
a.City.NameEn,
a.DistrictId,
a.DistrictId == null ? null : a.District.NameFa,
a.DistrictId == null ? null : a.District.NameEn,
a.AddressLine,
a.PostalCode,
a.Latitude,
a.Longitude,
a.IsPrimary,
a.RecipientName,
a.RecipientPhone))
.ToListAsync(cancellationToken);
return new PagedResult<CustomerAddressDto>(items, total, page, pageSize);
}
}
@@ -0,0 +1,121 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Geography;
using Baya.Domain.Entities.Geography;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class GeoRepository : IGeoRepository
{
private readonly ApplicationDbContext _db;
public GeoRepository(ApplicationDbContext db) => _db = db;
public async Task<IReadOnlyList<ProvinceDto>> ListActiveProvincesAsync(CancellationToken cancellationToken)
=> await _db.Set<Province>()
.AsNoTracking()
.Where(p => p.IsActive)
.OrderBy(p => p.SortOrder)
.Select(p => new ProvinceDto(p.Id, p.NameFa, p.NameEn, p.SortOrder))
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<CityDto>> ListActiveCitiesAsync(long provinceId, CancellationToken cancellationToken)
=> await _db.Set<City>()
.AsNoTracking()
.Where(c => c.ProvinceId == provinceId && c.IsActive && c.Province.IsActive)
.OrderBy(c => c.SortOrder)
.Select(c => new CityDto(c.Id, c.ProvinceId, c.NameFa, c.NameEn, c.SortOrder))
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<DistrictDto>> ListActiveDistrictsAsync(long cityId, CancellationToken cancellationToken)
=> await _db.Set<District>()
.AsNoTracking()
.Where(d => d.CityId == cityId && d.IsActive && d.City.IsActive && d.City.Province.IsActive)
.OrderBy(d => d.SortOrder)
.Select(d => new DistrictDto(d.Id, d.CityId, d.NameFa, d.NameEn, d.SortOrder))
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<ProvinceTreeDto>> GetActiveTreeAsync(CancellationToken cancellationToken)
{
// Three flat ordered reads assembled in memory — avoids a two-level nested collection projection
// (which SQLite can't translate) and keeps the payload cheap for a cached, near-static tree.
var provinces = await _db.Set<Province>()
.AsNoTracking()
.Where(p => p.IsActive)
.OrderBy(p => p.SortOrder)
.Select(p => new { p.Id, p.NameFa, p.NameEn, p.SortOrder })
.ToListAsync(cancellationToken);
var cities = await _db.Set<City>()
.AsNoTracking()
.Where(c => c.IsActive && c.Province.IsActive)
.OrderBy(c => c.SortOrder)
.Select(c => new { c.Id, c.ProvinceId, c.NameFa, c.NameEn, c.SortOrder })
.ToListAsync(cancellationToken);
var districts = await _db.Set<District>()
.AsNoTracking()
.Where(d => d.IsActive && d.City.IsActive && d.City.Province.IsActive)
.OrderBy(d => d.SortOrder)
.Select(d => new DistrictDto(d.Id, d.CityId, d.NameFa, d.NameEn, d.SortOrder))
.ToListAsync(cancellationToken);
var districtsByCity = districts
.GroupBy(d => d.CityId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<DistrictDto>)g.ToList());
var citiesByProvince = cities
.GroupBy(c => c.ProvinceId)
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<CityTreeDto>)g
.Select(c => new CityTreeDto(
c.Id,
c.NameFa,
c.NameEn,
c.SortOrder,
districtsByCity.TryGetValue(c.Id, out var ds) ? ds : []))
.ToList());
return provinces
.Select(p => new ProvinceTreeDto(
p.Id,
p.NameFa,
p.NameEn,
p.SortOrder,
citiesByProvince.TryGetValue(p.Id, out var cs) ? cs : []))
.ToList();
}
public Task<Province> GetProvinceAsync(long id, CancellationToken cancellationToken)
=> _db.Set<Province>().FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
public Task<City> GetCityAsync(long id, CancellationToken cancellationToken)
=> _db.Set<City>().FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
public Task<District> GetDistrictAsync(long id, CancellationToken cancellationToken)
=> _db.Set<District>().FirstOrDefaultAsync(d => d.Id == id, cancellationToken);
public async Task AddProvinceAsync(Province province, CancellationToken cancellationToken)
=> await _db.Set<Province>().AddAsync(province, cancellationToken);
public async Task AddCityAsync(City city, CancellationToken cancellationToken)
=> await _db.Set<City>().AddAsync(city, cancellationToken);
public async Task AddDistrictAsync(District district, CancellationToken cancellationToken)
=> await _db.Set<District>().AddAsync(district, cancellationToken);
public Task<bool> ProvinceExistsAsync(long provinceId, CancellationToken cancellationToken)
=> _db.Set<Province>().AsNoTracking().AnyAsync(p => p.Id == provinceId, cancellationToken);
public Task<bool> IsCityActiveAsync(long cityId, CancellationToken cancellationToken)
=> _db.Set<City>().AsNoTracking()
.AnyAsync(c => c.Id == cityId && c.IsActive && c.Province.IsActive, cancellationToken);
public Task<bool> IsDistrictInActiveCityAsync(long districtId, long cityId, CancellationToken cancellationToken)
=> _db.Set<District>().AsNoTracking()
.AnyAsync(
d => d.Id == districtId && d.CityId == cityId &&
d.IsActive && d.City.IsActive && d.City.Province.IsActive,
cancellationToken);
}
@@ -0,0 +1,52 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Baya.Domain.Entities.Geography;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class NurseServiceAreaRepository : BaseAsyncRepository<NurseServiceArea>, INurseServiceAreaRepository
{
public NurseServiceAreaRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(NurseServiceArea area, CancellationToken cancellationToken)
=> base.AddAsync(area);
public Task<NurseServiceArea> GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(a => a.Id == id && a.NurseId == nurseId, cancellationToken);
public Task<bool> DuplicateExistsAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken)
=> TableNoTracking.AnyAsync(
a => a.NurseId == nurseId && a.CityId == cityId && a.DistrictId == districtId,
cancellationToken);
public async Task<PagedResult<NurseServiceAreaDto>> ListAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken)
{
var query = TableNoTracking.Where(a => a.NurseId == nurseId);
var total = await query.CountAsync(cancellationToken);
var items = await query
// Whole-city rows (district_id NULL) first, then by id.
.OrderByDescending(a => a.DistrictId == null)
.ThenBy(a => a.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(a => new NurseServiceAreaDto(
a.Id,
a.CityId,
a.City.NameFa,
a.City.NameEn,
a.DistrictId,
a.DistrictId == null ? null : a.District.NameFa,
a.DistrictId == null ? null : a.District.NameEn,
a.DistrictId == null,
a.IsActive))
.ToListAsync(cancellationToken);
return new PagedResult<NurseServiceAreaDto>(items, total, page, pageSize);
}
}