refinement phase 1
This commit is contained in:
+12
-2
@@ -56,8 +56,9 @@ You are a **senior .NET software engineer** working on this codebase. That means
|
||||
| Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
|
||||
|
||||
**Default URL:** `https://localhost:5002` — Swagger at `/swagger`.
|
||||
On boot, `Program.cs` calls `ApplyMigrationsAsync()` and `SeedDefaultUsersAsync()` — a reachable SQL
|
||||
Server is required to start.
|
||||
On boot, `Program.cs` calls `ApplyMigrationsAsync()`, `SeedDefaultUsersAsync()`, `SeedPaymentGatewaysAsync()`
|
||||
— and, **only in Development**, `SeedDemoWorldAsync()` (the demo marketplace seeder, see Persistence below).
|
||||
A reachable SQL Server is required to start.
|
||||
|
||||
---
|
||||
|
||||
@@ -571,6 +572,15 @@ action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIO
|
||||
- Always project to a DTO in queries — never return entity objects from handlers.
|
||||
- Add entity config in `Persistence/Configuration/<Area>Config/` implementing `IEntityTypeConfiguration<T>`.
|
||||
- Soft delete is enforced via a global query filter per entity (see [CONVENTIONS.md](CONVENTIONS.md) §6).
|
||||
- **Development demo seeder (refinement-phase-1).** `Persistence/Services/Seeding/DemoWorldSeeder.cs`
|
||||
(+ `DemoWorldDefinitions.cs`) idempotently populates a coherent demo marketplace on top of the reference
|
||||
`HasData` seeds — 3 nurses (2 verified w/ variants + Tehran coverage + `approved` verification + credentials
|
||||
+ a `matched_national_id` bank account, 1 unverified), 2 customers (patients + addresses), and one
|
||||
cross-category required demo option group (شیفت / *Shift Type*). It writes through the real entities and
|
||||
drives the search projection through `ISearchIndexMaintainer.RebuildAsync` (never hand-inserts index rows),
|
||||
guarding each persona on its phone number so re-runs are a no-op. Invoked via `SeedDemoWorldAsync()`
|
||||
**only under `IsDevelopment()`** — never in Production/Staging. The demo world (phones, which nurse is
|
||||
verified) is in `dev/post-phase/refinement/RUNBOOK.md`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -107,6 +107,11 @@ if (!app.Environment.IsEnvironment("Testing"))
|
||||
await app.ApplyMigrationsAsync();
|
||||
await app.SeedDefaultUsersAsync();
|
||||
await app.SeedPaymentGatewaysAsync();
|
||||
|
||||
// Development-only: populate a demo marketplace (nurses/variants/search rows, customers/patients)
|
||||
// so the real-path screens aren't empty. Idempotent; never runs in Production/Staging.
|
||||
if (app.Environment.IsDevelopment())
|
||||
await app.SeedDemoWorldAsync();
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
|
||||
+1
@@ -15,6 +15,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Baya.Test.Foundation" />
|
||||
<InternalsVisibleTo Include="Baya.Test.Api" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+19
@@ -19,6 +19,7 @@ using Baya.Infrastructure.Persistence.Services.Holidays;
|
||||
using Baya.Infrastructure.Persistence.Services.Notifications;
|
||||
using Baya.Infrastructure.Persistence.Services.Payments;
|
||||
using Baya.Infrastructure.Persistence.Services.Search;
|
||||
using Baya.Infrastructure.Persistence.Services.Seeding;
|
||||
using Baya.Infrastructure.Persistence.Services.SupportAlerts;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -78,6 +79,10 @@ public static class ServiceCollectionExtensions
|
||||
throw new NotSupportedException(
|
||||
$"Search backend '{searchBackend}' is not available — only 'sql' is implemented (Elasticsearch is deferred).");
|
||||
|
||||
// Development-only demo marketplace seeder. Registered always (cheap), but invoked from Program.cs
|
||||
// only under IsDevelopment() — never against a production/staging DB.
|
||||
services.AddScoped<DemoWorldSeeder>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -118,4 +123,18 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a coherent <b>Development-only</b> demo marketplace (verified/unverified nurses with variants +
|
||||
/// Tehran coverage, demo customers with patients + addresses) on top of the reference seeds, so every
|
||||
/// real-path discovery/search/booking screen has data. Idempotent — guarded on each persona's phone —
|
||||
/// and drives the search projection through the real maintainer. Callers must gate this on
|
||||
/// <c>IsDevelopment()</c>: seeding fake accounts into a real DB would be a trust/data-integrity disaster.
|
||||
/// </summary>
|
||||
public static async Task SeedDemoWorldAsync(this WebApplication app)
|
||||
{
|
||||
await using var scope = app.Services.CreateAsyncScope();
|
||||
var seeder = scope.ServiceProvider.GetRequiredService<DemoWorldSeeder>();
|
||||
await seeder.SeedAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Seeding;
|
||||
|
||||
/// <summary>
|
||||
/// The static, deterministic description of the Development demo marketplace the <see cref="DemoWorldSeeder"/>
|
||||
/// materialises. Kept as plain data (not inline in the seeder) so the exact demo world — the phone numbers a
|
||||
/// developer logs in with, which nurse is verified, the priced variants and coverage — reads at a glance and
|
||||
/// stays the single source of truth for the runbook / frontend de-mock hand-off.
|
||||
/// <para>
|
||||
/// All money is <b>IRR Rials as an integer</b> (no Toman, no float). Category ids are the fixed catalog seed
|
||||
/// ids (<c>1…5</c>); the Tehran city id and district ids are the fixed geography seed ids.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class DemoWorldDefinitions
|
||||
{
|
||||
public const long TehranCityId = GeographySeed.TehranCityId;
|
||||
|
||||
// Tehran municipal district ids from the geography seed (1000 + n).
|
||||
public const long District1 = 1001;
|
||||
public const long District2 = 1002;
|
||||
public const long District3 = 1003;
|
||||
public const long District6 = 1006;
|
||||
public const long District12 = 1012;
|
||||
|
||||
// Catalog category ids from the catalog seed.
|
||||
public const long ElderlyCare = 1;
|
||||
public const long PostSurgery = 2;
|
||||
public const long InfantCare = 3;
|
||||
public const long ChronicIllness = 4;
|
||||
|
||||
/// <summary>The single cross-category, required demo pricing dimension (شیفت / shift type). Seeding it in
|
||||
/// Development gives the frontend variant builder a required option-group to render on the real path
|
||||
/// (the "fresh catalog has categories but no option groups" data gap) without polluting a production DB.</summary>
|
||||
public const string ShiftGroupNameEn = "Shift Type";
|
||||
public const string ShiftGroupNameFa = "نوع شیفت";
|
||||
|
||||
public const string ShiftDaytime = "Daytime";
|
||||
public const string ShiftNight = "Night";
|
||||
public const string ShiftLiveIn = "Live-in";
|
||||
|
||||
public static readonly (string NameFa, string NameEn)[] ShiftValues =
|
||||
[
|
||||
("روزانه", ShiftDaytime),
|
||||
("شبانه", ShiftNight),
|
||||
("شبانهروزی", ShiftLiveIn),
|
||||
];
|
||||
|
||||
public static readonly NursePersona[] Nurses =
|
||||
[
|
||||
new NursePersona(
|
||||
Phone: "09120000001",
|
||||
UserName: "demo_nurse_azizi",
|
||||
Name: "زهرا",
|
||||
FamilyName: "عزیزی",
|
||||
Gender: "female",
|
||||
NationalId: "0012345678",
|
||||
Bio: "پرستار سالمند با هشت سال سابقه مراقبت در منزل.",
|
||||
YearsOfExperience: 8,
|
||||
IsVerified: true,
|
||||
Credentials:
|
||||
[
|
||||
new CredentialDef(CredentialTypes.MohCompetencyLicense, "پروانه صلاحیت وزارت بهداشت", "MOH-84512"),
|
||||
new CredentialDef(CredentialTypes.CriminalRecord, "گواهی عدم سوء پیشینه", "CR-2025-1187"),
|
||||
],
|
||||
Bank: new BankDef("بانک ملت", "زهرا عزیزی", "IR820170000000123456789012", MatchedNationalId: true),
|
||||
Variants:
|
||||
[
|
||||
new VariantDef(ElderlyCare, PriceUnits.Per24H, 3_500_000, SessionCount: null, ShiftLiveIn, "مراقبت شبانهروزی سالمند"),
|
||||
new VariantDef(ElderlyCare, PriceUnits.PerHour, 250_000, SessionCount: null, ShiftDaytime, "مراقبت ساعتی سالمند"),
|
||||
new VariantDef(PostSurgery, PriceUnits.PerDay, 2_000_000, SessionCount: null, ShiftDaytime, "مراقبت روزانه پس از جراحی"),
|
||||
],
|
||||
Areas:
|
||||
[
|
||||
new AreaDef(TehranCityId, null), // whole-city Tehran
|
||||
new AreaDef(TehranCityId, District1),
|
||||
new AreaDef(TehranCityId, District3),
|
||||
]),
|
||||
|
||||
new NursePersona(
|
||||
Phone: "09120000002",
|
||||
UserName: "demo_nurse_karimi",
|
||||
Name: "علی",
|
||||
FamilyName: "کریمی",
|
||||
Gender: "male",
|
||||
NationalId: "0023456789",
|
||||
Bio: "پرستار مراقبت پس از جراحی و بیماریهای مزمن.",
|
||||
YearsOfExperience: 5,
|
||||
IsVerified: true,
|
||||
Credentials:
|
||||
[
|
||||
new CredentialDef(CredentialTypes.MohCompetencyLicense, "پروانه صلاحیت وزارت بهداشت", "MOH-77120"),
|
||||
],
|
||||
Bank: new BankDef("بانک ملی", "علی کریمی", "IR330120000000098765432101", MatchedNationalId: true),
|
||||
Variants:
|
||||
[
|
||||
new VariantDef(ChronicIllness, PriceUnits.PerDay, 1_800_000, SessionCount: null, ShiftDaytime, "مدیریت روزانه بیماری مزمن"),
|
||||
new VariantDef(PostSurgery, PriceUnits.Per24H, 3_200_000, SessionCount: null, ShiftLiveIn, "مراقبت شبانهروزی پس از جراحی"),
|
||||
],
|
||||
Areas:
|
||||
[
|
||||
new AreaDef(TehranCityId, District3),
|
||||
new AreaDef(TehranCityId, District6),
|
||||
new AreaDef(TehranCityId, District12),
|
||||
]),
|
||||
|
||||
// Unverified: has a profile + a variant + a covered area, but is_verified=0 so the search-index
|
||||
// maintainer computes is_searchable=0 and this nurse must never surface in discovery.
|
||||
new NursePersona(
|
||||
Phone: "09120000003",
|
||||
UserName: "demo_nurse_ahmadi",
|
||||
Name: "مریم",
|
||||
FamilyName: "احمدی",
|
||||
Gender: "female",
|
||||
NationalId: null,
|
||||
Bio: "در انتظار تکمیل مراحل احراز هویت.",
|
||||
YearsOfExperience: 2,
|
||||
IsVerified: false,
|
||||
Credentials: [],
|
||||
Bank: null,
|
||||
Variants:
|
||||
[
|
||||
new VariantDef(InfantCare, PriceUnits.PerSession, 800_000, SessionCount: 1, ShiftDaytime, "مراقبت جلسهای نوزاد"),
|
||||
],
|
||||
Areas:
|
||||
[
|
||||
new AreaDef(TehranCityId, District2),
|
||||
]),
|
||||
];
|
||||
|
||||
public static readonly CustomerPersona[] Customers =
|
||||
[
|
||||
new CustomerPersona(
|
||||
Phone: "09120000010",
|
||||
UserName: "demo_customer_mohammadi",
|
||||
Name: "سارا",
|
||||
FamilyName: "محمدی",
|
||||
Gender: "female",
|
||||
EmergencyContactName: "بهرام محمدی",
|
||||
EmergencyContactPhone: "09121110010",
|
||||
Patients:
|
||||
[
|
||||
new PatientDef("حسن محمدی", "حسن", "محمدی", "male", new DateOnly(1948, 3, 15), "A+", "سابقه فشار خون و دیابت."),
|
||||
new PatientDef("فاطمه محمدی", "فاطمه", "محمدی", "female", new DateOnly(1955, 9, 2), "O+", "بدون سابقه بیماری خاص."),
|
||||
],
|
||||
Addresses:
|
||||
[
|
||||
new AddressDef("خانه", District3, "تهران، ونک، خیابان ملاصدرا، پلاک ۱۲", "1991834511", "بهرام محمدی", "09121110010", 35.7595m, 51.4100m, IsPrimary: true),
|
||||
]),
|
||||
|
||||
new CustomerPersona(
|
||||
Phone: "09120000011",
|
||||
UserName: "demo_customer_hosseini",
|
||||
Name: "رضا",
|
||||
FamilyName: "حسینی",
|
||||
Gender: "male",
|
||||
EmergencyContactName: "نازنین حسینی",
|
||||
EmergencyContactPhone: "09121110011",
|
||||
Patients:
|
||||
[
|
||||
new PatientDef("امیرعلی حسینی", "امیرعلی", "حسینی", "male", new DateOnly(2025, 1, 20), "B+", "نوزاد سالم، نیازمند مراقبت روزانه."),
|
||||
],
|
||||
Addresses:
|
||||
[
|
||||
new AddressDef("خانه", District6, "تهران، سعادتآباد، بلوار دریا، پلاک ۴۵", "1998765432", "نازنین حسینی", "09121110011", 35.7870m, 51.3760m, IsPrimary: true),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
internal sealed record NursePersona(
|
||||
string Phone,
|
||||
string UserName,
|
||||
string Name,
|
||||
string FamilyName,
|
||||
string Gender,
|
||||
string? NationalId,
|
||||
string Bio,
|
||||
int YearsOfExperience,
|
||||
bool IsVerified,
|
||||
CredentialDef[] Credentials,
|
||||
BankDef? Bank,
|
||||
VariantDef[] Variants,
|
||||
AreaDef[] Areas);
|
||||
|
||||
internal sealed record CredentialDef(string Type, string IssuingAuthority, string Number);
|
||||
|
||||
internal sealed record BankDef(string BankName, string AccountHolderName, string Iban, bool MatchedNationalId);
|
||||
|
||||
internal sealed record VariantDef(
|
||||
long CategoryId,
|
||||
string PriceUnit,
|
||||
long Price,
|
||||
int? SessionCount,
|
||||
string ShiftValueEn,
|
||||
string DisplayName);
|
||||
|
||||
internal sealed record AreaDef(long CityId, long? DistrictId);
|
||||
|
||||
internal sealed record CustomerPersona(
|
||||
string Phone,
|
||||
string UserName,
|
||||
string Name,
|
||||
string FamilyName,
|
||||
string Gender,
|
||||
string EmergencyContactName,
|
||||
string EmergencyContactPhone,
|
||||
PatientDef[] Patients,
|
||||
AddressDef[] Addresses);
|
||||
|
||||
internal sealed record PatientDef(
|
||||
string DisplayName,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string Gender,
|
||||
DateOnly BirthDate,
|
||||
string BloodType,
|
||||
string InitialMedicalNotes);
|
||||
|
||||
internal sealed record AddressDef(
|
||||
string Title,
|
||||
long DistrictId,
|
||||
string AddressLine,
|
||||
string PostalCode,
|
||||
string RecipientName,
|
||||
string RecipientPhone,
|
||||
decimal Latitude,
|
||||
decimal Longitude,
|
||||
bool IsPrimary);
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Identity;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Seeding;
|
||||
|
||||
/// <summary>
|
||||
/// Development-only seeder that turns a freshly-migrated database (reference/lookup rows + one admin + one
|
||||
/// gateway) into a <b>populated demo marketplace</b>: verified nurses with priced variants and Tehran
|
||||
/// coverage, an unverified nurse, and demo customers with patients and addresses. Without it every real-path
|
||||
/// discovery/search/booking screen renders empty because the reference seed contains no transactional data.
|
||||
/// <para>
|
||||
/// It seeds the <i>causes</i> (verification flip, active variants, covered areas) and lets the real
|
||||
/// <see cref="ISearchIndexMaintainer"/> compute the <c>nurse_search_index</c> — never hand-inserting index
|
||||
/// rows — so the seeded world is identical to one built by real usage and the <c>is_searchable</c> invariant
|
||||
/// stays truthful. Every insert is guarded on a stable natural key (the persona's phone number, the option
|
||||
/// group name), so re-running on an already-seeded DB is a no-op.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class DemoWorldSeeder(
|
||||
ApplicationDbContext db,
|
||||
IAppUserManager userManager,
|
||||
ISearchIndexMaintainer searchIndex,
|
||||
IFieldEncryptor encryptor,
|
||||
IDateTimeProvider clock,
|
||||
ILogger<DemoWorldSeeder> logger)
|
||||
{
|
||||
public async Task SeedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var (shiftGroupId, shiftValueIdByCode) = await EnsureShiftTypeGroupAsync(cancellationToken);
|
||||
|
||||
var seededNurses = 0;
|
||||
foreach (var persona in DemoWorldDefinitions.Nurses)
|
||||
if (await EnsureNurseAsync(persona, shiftGroupId, shiftValueIdByCode, cancellationToken))
|
||||
seededNurses++;
|
||||
|
||||
var seededCustomers = 0;
|
||||
foreach (var persona in DemoWorldDefinitions.Customers)
|
||||
if (await EnsureCustomerAsync(persona, cancellationToken))
|
||||
seededCustomers++;
|
||||
|
||||
// Re-derive the whole search projection from source. Idempotent (drops + rebuilds), and the single
|
||||
// place is_searchable is computed — verified+accepting nurses with an active variant surface; the
|
||||
// unverified nurse does not.
|
||||
var rebuild = await searchIndex.RebuildAsync(cancellationToken);
|
||||
|
||||
if (seededNurses == 0 && seededCustomers == 0)
|
||||
logger.LogInformation(
|
||||
"Demo world already seeded — no-op. Search index re-derived: {Nurses} nurses, {Rows} rows.",
|
||||
rebuild.NursesProcessed, rebuild.RowsWritten);
|
||||
else
|
||||
logger.LogInformation(
|
||||
"Demo world seeded: {Nurses} nurse(s), {Customers} customer(s). Search index: {IndexNurses} nurses, {Rows} searchable-eligible rows.",
|
||||
seededNurses, seededCustomers, rebuild.NursesProcessed, rebuild.RowsWritten);
|
||||
}
|
||||
|
||||
private async Task<(long GroupId, IReadOnlyDictionary<string, long> ValueIdByCode)> EnsureShiftTypeGroupAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var group = await db.Set<ServiceOptionGroup>()
|
||||
.Include(g => g.Values)
|
||||
.FirstOrDefaultAsync(
|
||||
g => g.ServiceCategoryId == null && g.NameEn == DemoWorldDefinitions.ShiftGroupNameEn,
|
||||
cancellationToken);
|
||||
|
||||
if (group is null)
|
||||
{
|
||||
group = new ServiceOptionGroup
|
||||
{
|
||||
ServiceCategoryId = null, // cross-category: applies to every category
|
||||
NameFa = DemoWorldDefinitions.ShiftGroupNameFa,
|
||||
NameEn = DemoWorldDefinitions.ShiftGroupNameEn,
|
||||
IsRequired = true,
|
||||
SortOrder = 1,
|
||||
IsActive = true,
|
||||
Values = DemoWorldDefinitions.ShiftValues
|
||||
.Select((v, i) => new ServiceOptionValue
|
||||
{
|
||||
NameFa = v.NameFa,
|
||||
NameEn = v.NameEn,
|
||||
SortOrder = i + 1,
|
||||
IsActive = true
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
|
||||
db.Set<ServiceOptionGroup>().Add(group);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var valueIdByCode = group.Values.ToDictionary(v => v.NameEn, v => v.Id);
|
||||
return (group.Id, valueIdByCode);
|
||||
}
|
||||
|
||||
/// <returns><c>true</c> if the persona was newly created; <c>false</c> if it already existed.</returns>
|
||||
private async Task<bool> EnsureNurseAsync(
|
||||
NursePersona persona,
|
||||
long shiftGroupId,
|
||||
IReadOnlyDictionary<string, long> shiftValueIdByCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await userManager.GetUserByPhoneNumber(persona.Phone) is not null)
|
||||
return false;
|
||||
|
||||
var user = await CreateUserAsync(persona.UserName, persona.Phone, persona.Name, persona.FamilyName,
|
||||
persona.Gender, persona.NationalId, RoleNames.Nurse, cancellationToken);
|
||||
|
||||
var now = clock.UtcNow;
|
||||
|
||||
var profile = new NurseProfile
|
||||
{
|
||||
UserId = user.Id,
|
||||
Bio = persona.Bio,
|
||||
YearsOfExperience = persona.YearsOfExperience,
|
||||
EducationLevel = "کارشناسی",
|
||||
EducationField = "پرستاری",
|
||||
SpecializationsJson = "[]"
|
||||
};
|
||||
profile.SetAcceptingBookings(true);
|
||||
db.Set<NurseProfile>().Add(profile);
|
||||
await db.SaveChangesAsync(cancellationToken); // assigns profile.Id used by the FK-only child rows
|
||||
|
||||
// Verification record: the single source of verification truth. A verified nurse is approved; the
|
||||
// profile's guarded is_verified flag is flipped through its sanctioned write path (mirroring b6's
|
||||
// finalize), which is what the search maintainer and trust badge read.
|
||||
var verification = new NurseVerification
|
||||
{
|
||||
NurseId = profile.Id,
|
||||
Status = persona.IsVerified ? VerificationStatus.Approved : VerificationStatus.Pending,
|
||||
SubmittedAt = now,
|
||||
ApprovedAt = persona.IsVerified ? now : null
|
||||
};
|
||||
db.Set<NurseVerification>().Add(verification);
|
||||
|
||||
if (persona.IsVerified)
|
||||
profile.MarkVerified();
|
||||
|
||||
foreach (var credential in persona.Credentials)
|
||||
db.Set<NurseCredential>().Add(new NurseCredential
|
||||
{
|
||||
NurseId = profile.Id,
|
||||
CredentialType = credential.Type,
|
||||
CredentialNumber = credential.Number, // encrypted at rest by the value converter
|
||||
HolderNameSnapshot = $"{persona.Name} {persona.FamilyName}",
|
||||
IssuingAuthority = credential.IssuingAuthority,
|
||||
IssuedAt = DateOnly.FromDateTime(now.UtcDateTime).AddYears(-1),
|
||||
ExpiresAt = DateOnly.FromDateTime(now.UtcDateTime).AddYears(2),
|
||||
VerificationMethod = VerificationMethods.Manual
|
||||
});
|
||||
|
||||
if (persona.Bank is { } bank)
|
||||
{
|
||||
var iban = bank.Iban;
|
||||
db.Set<NurseBankAccount>().Add(new NurseBankAccount
|
||||
{
|
||||
NurseId = profile.Id,
|
||||
BankName = bank.BankName,
|
||||
AccountHolderName = bank.AccountHolderName, // encrypted at rest by the value converter
|
||||
Iban = iban, // encrypted at rest by the value converter
|
||||
IbanHash = encryptor.Hash(iban), // deterministic lookup/uniqueness column
|
||||
IsPrimary = true,
|
||||
IsVerified = bank.MatchedNationalId,
|
||||
VerifiedAt = bank.MatchedNationalId ? now : null,
|
||||
MatchedNationalId = bank.MatchedNationalId,
|
||||
AccountHolderFromBank = bank.MatchedNationalId ? bank.AccountHolderName : null!,
|
||||
OwnershipVendorRef = bank.MatchedNationalId ? $"demo-sheba-{profile.Id}" : null!
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var variantDef in persona.Variants)
|
||||
{
|
||||
var shiftValueId = shiftValueIdByCode[variantDef.ShiftValueEn];
|
||||
var optionPairs = new[] { (shiftGroupId, shiftValueId) };
|
||||
|
||||
db.Set<NurseServiceVariant>().Add(new NurseServiceVariant
|
||||
{
|
||||
NurseId = profile.Id,
|
||||
ServiceCategoryId = variantDef.CategoryId,
|
||||
Price = variantDef.Price,
|
||||
PriceUnit = variantDef.PriceUnit,
|
||||
SessionCount = variantDef.SessionCount,
|
||||
DisplayName = variantDef.DisplayName,
|
||||
OptionSetHash = OptionSetHash.Compute(optionPairs),
|
||||
IsActive = true,
|
||||
Options = new List<NurseServiceVariantOption>
|
||||
{
|
||||
new() { OptionGroupId = shiftGroupId, OptionValueId = shiftValueId }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var area in persona.Areas)
|
||||
db.Set<NurseServiceArea>().Add(new NurseServiceArea
|
||||
{
|
||||
NurseId = profile.Id,
|
||||
CityId = area.CityId,
|
||||
DistrictId = area.DistrictId,
|
||||
IsActive = true
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> EnsureCustomerAsync(CustomerPersona persona, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await userManager.GetUserByPhoneNumber(persona.Phone) is not null)
|
||||
return false;
|
||||
|
||||
var user = await CreateUserAsync(persona.UserName, persona.Phone, persona.Name, persona.FamilyName,
|
||||
persona.Gender, nationalId: null, RoleNames.Customer, cancellationToken);
|
||||
|
||||
var profile = new CustomerProfile
|
||||
{
|
||||
UserId = user.Id,
|
||||
DefaultEmergencyContactName = persona.EmergencyContactName, // encrypted at rest
|
||||
DefaultEmergencyContactPhone = persona.EmergencyContactPhone // encrypted at rest
|
||||
};
|
||||
db.Set<CustomerProfile>().Add(profile);
|
||||
await db.SaveChangesAsync(cancellationToken); // assigns profile.Id used by the FK-only child rows
|
||||
|
||||
foreach (var patient in persona.Patients)
|
||||
db.Set<Patient>().Add(new Patient
|
||||
{
|
||||
CustomerId = profile.Id,
|
||||
DisplayName = patient.DisplayName,
|
||||
FirstName = patient.FirstName,
|
||||
LastName = patient.LastName,
|
||||
Gender = patient.Gender,
|
||||
BirthDate = patient.BirthDate,
|
||||
BloodType = patient.BloodType,
|
||||
InitialMedicalNotes = patient.InitialMedicalNotes, // encrypted at rest
|
||||
IsActive = true
|
||||
});
|
||||
|
||||
foreach (var address in persona.Addresses)
|
||||
db.Set<CustomerAddress>().Add(new CustomerAddress
|
||||
{
|
||||
CustomerId = profile.Id,
|
||||
CityId = DemoWorldDefinitions.TehranCityId,
|
||||
DistrictId = address.DistrictId,
|
||||
Title = address.Title,
|
||||
AddressLine = address.AddressLine, // encrypted at rest
|
||||
PostalCode = address.PostalCode, // encrypted at rest
|
||||
Latitude = address.Latitude,
|
||||
Longitude = address.Longitude,
|
||||
IsPrimary = address.IsPrimary,
|
||||
RecipientName = address.RecipientName, // encrypted at rest
|
||||
RecipientPhone = address.RecipientPhone // encrypted at rest
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<User> CreateUserAsync(
|
||||
string userName, string phone, string name, string familyName, string gender,
|
||||
string? nationalId, string roleName, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
UserName = userName,
|
||||
PhoneNumber = phone, // encrypted at rest; PhoneHash synced on SaveChanges
|
||||
PhoneNumberConfirmed = true, // so the demo account logs in immediately via the OTP flow
|
||||
PhoneVerifiedAt = clock.UtcNow,
|
||||
Name = name,
|
||||
FamilyName = familyName,
|
||||
Gender = gender,
|
||||
NationalId = nationalId, // encrypted at rest (null for the unverified/customer personas)
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
var createResult = await userManager.CreateUser(user);
|
||||
if (!createResult.Succeeded)
|
||||
throw new InvalidOperationException(
|
||||
$"Demo seed failed to create user '{userName}': {string.Join("; ", createResult.Errors.Select(e => e.Description))}");
|
||||
|
||||
var role = await db.Set<Role>().FirstOrDefaultAsync(r => r.Name == roleName, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Role '{roleName}' is not seeded — cannot assign it to the demo user.");
|
||||
await userManager.AddUserToRoleAsync(user, role);
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Net;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Infrastructure.Persistence;
|
||||
using Baya.Infrastructure.Persistence.Services.Seeding;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Baya.Test.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Proves the Development demo seeder end-to-end over the real HTTP pipeline (refinement-phase-1): running it
|
||||
/// populates a coherent marketplace, the verified nurses surface on the public search route, the unverified
|
||||
/// one does not, the trust badge reflects verification, and a second run is a no-op (idempotent).
|
||||
/// </summary>
|
||||
public class DemoWorldSeederTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
|
||||
{
|
||||
private const long TehranCityId = 101;
|
||||
private const long ElderlyCareCategoryId = 1; // offered only by the verified nurses
|
||||
private const long InfantCareCategoryId = 3; // offered only by the unverified nurse
|
||||
|
||||
private async Task RunSeederAsync()
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var seeder = scope.ServiceProvider.GetRequiredService<DemoWorldSeeder>();
|
||||
await seeder.SeedAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Seed_Then_Search_VerifiedNurseSurfaces_UnverifiedDoesNot()
|
||||
{
|
||||
await RunSeederAsync();
|
||||
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Elderly care is offered only by the verified nurses → at least one searchable result.
|
||||
var elderly = await client.GetAsync(
|
||||
$"/api/v1/search/nurses?service_category_id={ElderlyCareCategoryId}&city_id={TehranCityId}");
|
||||
Assert.Equal(HttpStatusCode.OK, elderly.StatusCode);
|
||||
var elderlyData = await AuthTestClient.ReadDataAsync(elderly);
|
||||
Assert.True(elderlyData.GetProperty("total").GetInt32() > 0);
|
||||
|
||||
// Infant care is offered only by the unverified nurse → is_searchable=0, so nothing surfaces.
|
||||
var infant = await client.GetAsync(
|
||||
$"/api/v1/search/nurses?service_category_id={InfantCareCategoryId}&city_id={TehranCityId}");
|
||||
Assert.Equal(HttpStatusCode.OK, infant.StatusCode);
|
||||
var infantData = await AuthTestClient.ReadDataAsync(infant);
|
||||
Assert.Equal(0, infantData.GetProperty("total").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Seed_VerifiedNurse_HasVerifiedBadge_UnverifiedDoesNot()
|
||||
{
|
||||
await RunSeederAsync();
|
||||
|
||||
long verifiedNurseId;
|
||||
long unverifiedNurseId;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
verifiedNurseId = await db.Set<NurseProfile>().Where(p => p.IsVerified).Select(p => p.Id).FirstAsync();
|
||||
unverifiedNurseId = await db.Set<NurseProfile>().Where(p => !p.IsVerified).Select(p => p.Id).FirstAsync();
|
||||
}
|
||||
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var verified = await client.GetAsync($"/api/v1/nurses/{verifiedNurseId}/trust_badge");
|
||||
Assert.Equal(HttpStatusCode.OK, verified.StatusCode);
|
||||
var verifiedBadge = await AuthTestClient.ReadDataAsync(verified);
|
||||
Assert.True(verifiedBadge.GetProperty("isVerified").GetBoolean());
|
||||
Assert.NotEmpty(verifiedBadge.GetProperty("credentialTypes").EnumerateArray());
|
||||
|
||||
var unverified = await client.GetAsync($"/api/v1/nurses/{unverifiedNurseId}/trust_badge");
|
||||
Assert.Equal(HttpStatusCode.OK, unverified.StatusCode);
|
||||
var unverifiedBadge = await AuthTestClient.ReadDataAsync(unverified);
|
||||
Assert.False(unverifiedBadge.GetProperty("isVerified").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Seed_IsIdempotent_SecondRunAddsNoDuplicates()
|
||||
{
|
||||
await RunSeederAsync();
|
||||
await RunSeederAsync();
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
Assert.Equal(DemoWorldDefinitions.Nurses.Length, await db.Set<NurseProfile>().CountAsync());
|
||||
Assert.Equal(DemoWorldDefinitions.Customers.Length, await db.Set<CustomerProfile>().CountAsync());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user