diff --git a/dev/post-phase/refinement/RUNBOOK.md b/dev/post-phase/refinement/RUNBOOK.md index eefd375..3ff804c 100644 --- a/dev/post-phase/refinement/RUNBOOK.md +++ b/dev/post-phase/refinement/RUNBOOK.md @@ -79,6 +79,11 @@ On boot the API applies all EF migrations and seeds roles + an admin user + a sa against the (empty) local DB, then listens on **`https://localhost:5002`** — Swagger at `https://localhost:5002/swagger`. +In **Development** it additionally runs the **demo-world seeder** (Refinement Phase 1): verified/unverified +demo nurses with priced variants + Tehran coverage (and therefore real `nurse_search_index` rows), plus demo +customers with patients and addresses. The console logs `Demo world seeded: 3 nurse(s), 2 customer(s)…` (or +`already seeded — no-op` on a subsequent run). It is **idempotent** and **never runs outside Development**. + ### Terminal 2 — frontend (from `client/`) ```bash @@ -93,10 +98,29 @@ Serves **`http://localhost:3000`**. It reads the API base URL from `client/.env. --- +## Demo accounts (Development seed) + +The demo seeder creates these loginable accounts (phone-OTP; any 6-digit code you read from the console/dev +endpoint works). Use them to see the real path populated: + +| Phone | Role | Who | State | +| --- | --- | --- | --- | +| `09120000001` | nurse | زهرا عزیزی (female) | **verified**, 3 variants, whole-city + 2 districts | +| `09120000002` | nurse | علی کریمی (male) | **verified**, 2 variants, 3 districts | +| `09120000003` | nurse | مریم احمدی (female) | **unverified** — not discoverable in search | +| `09120000010` | customer | سارا محمدی (female) | 2 patients, 1 Tehran address | +| `09120000011` | customer | رضا حسینی (male) | 1 patient, 1 Tehran address | +| `admin` / `qw123321` | admin | seeded admin (username+password) | backoffice | + +Prove search works without the frontend: open Swagger → +`GET /api/v1/search/nurses?service_category_id=1&city_id=101` returns the two verified nurses' variants; +`service_category_id=3` (only the unverified nurse) returns an empty page. + ## Log in (the real round-trip) 1. Open **`http://localhost:3000/fa/login`**. -2. Enter an Iranian mobile number (e.g. `09120000001`) and request the code. +2. Enter an Iranian mobile number (e.g. the verified nurse `09120000001`, or any demo phone above) and request + the code. 3. Get the 6-digit OTP one of two ways: - **Read the server console** (Terminal 1) — SMS is mocked, so the code is logged: `MOCK SMS — OTP code 123456 for phone ending in 0001`. @@ -118,8 +142,18 @@ Serves **`http://localhost:3000`**. It reads the API base URL from `client/.env. automatically, so `fetch` just works; for `curl` add `--http2`. - **Only `auth` is real by default.** 21 of 22 client service domains default to an in-browser mock (`USE_*_MOCK = true`); the home, search, bookings, etc. are fake in-memory data until Refinement Phase 4. -- **The DB self-migrates + self-seeds**, so pointing at an empty local instance is enough. Rich demo data - (nurses, variants, search rows) arrives in [Refinement Phase 1](refinement-phase-1-database-and-seed.md). +- **The DB self-migrates + self-seeds**, so pointing at an empty local instance is enough — including the + Development demo world (nurses, variants, search rows, customers) from + [Refinement Phase 1](refinement-phase-1-database-and-seed.md). +- **Explicit migration path (deploy / migrations-off-boot).** Boot-time `MigrateAsync` is the dev convenience; + to apply migrations without booting the app (the direction [Refinement Phase 7](refinement-phase-7-unattended-ops.md) + will formalise), run from `server/`: + ```bash + dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api + ``` + (`dotnet tool install --global dotnet-ef` if the `ef` command is missing. It reads the same connection + string, so set the user-secret / env var first.) This applies **schema only** — the reference + demo seeds + run on the next app boot. - **Allowed browser origins** are configuration-driven (`Cors:AllowedOrigins`), defaulting to `http://localhost:3000` in Development. A deployed environment lists its real web origin(s). @@ -127,9 +161,13 @@ Serves **`http://localhost:3000`**. It reads the API base URL from `client/.env. ```bash docker compose down # stop the DB, keep its data -docker compose down -v # stop the DB and wipe the volume (fresh migrate + seed next run) +docker compose down -v # stop the DB and wipe the volume (fresh migrate + reference + demo seed next run) ``` +Re-running `dotnet run` against an **existing** seeded DB does not duplicate anything — both the reference and +the demo seeders are idempotent (the demo seeder guards each persona on its phone number). To get a clean demo +world, wipe the volume (`docker compose down -v`) and boot again. + ## Troubleshooting | Symptom | Fix | diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index 0c4464c..73652bb 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,31 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## refinement-phase-1 — Database: local-dev story, demo seed & migration hygiene — 2026-07-13 +- **Shipped (no migration, no endpoint, no contract change):** Development-gated **demo-world seeder** — + `Persistence/Services/Seeding/DemoWorldSeeder.cs` + `DemoWorldDefinitions.cs`, scoped-registered in + `AddPersistenceServices`, invoked from `Program.cs` via new `SeedDemoWorldAsync()` **only under + `IsDevelopment()`**. Idempotently (guarded on phone) creates **3 nurses** (2 verified: `MarkVerified()` + + `nurse_verifications` `approved` + credentials + `matched_national_id` primary bank + 2–3 IRR variants + + Tehran areas incl. whole-city `district_id=NULL`; 1 unverified), **2 customers** (patients + coord + addresses), and **1 cross-category required demo option group** (شیفت / Shift Type). Search rows are driven + through the real `ISearchIndexMaintainer.RebuildAsync` — never hand-inserted, so `is_searchable` stays + truthful (verified surface, unverified doesn't). Reference `HasData` seeds + the 17 migrations untouched. +- **Local DB / migration hygiene:** verified Phase 0's compose + placeholder connstrings + boot-migrate; + documented the explicit `dotnet ef database update` path + demo-reset flow in the RUNBOOK; confirmed no + pending model changes. Forward-dep FKs (Phase 6) and the boot-migrate multi-instance split (Phase 7) are + out of scope by design. +- **Contracts:** none produced; swagger snapshot **not** regenerated (no route/shape change). +- **Mocked:** none introduced (the seeder uses the real handlers/maintainer/converters). +- **Gate:** build clean (0 new warnings) / tests green (**372**: +3 `DemoWorldSeederTests` proving over the + real HTTP pipeline — verified nurse in search, unverified not, trust badge correct, idempotent). Added + `InternalsVisibleTo("Baya.Test.Api")` on Persistence for the seeder test. +- **Handoff:** backend/handoff/after-refinement-phase-1.md +- **Notes for frontend:** demo accounts to log in as (phone-OTP) — nurses `09120000001` (verified) / + `09120000002` (verified) / `09120000003` (unverified); customers `09120000010` / `09120000011`. Tehran + `city_id=101`. `GET /search/nurses?service_category_id=1&city_id=101` now returns real verified nurses. + **No `USE_*_MOCK` flag flipped — de-mocking is Phase 4.** + ## refinement-phase-0 — Local end-to-end bring-up & the integration seam — 2026-07-12 - **Shipped (integration/plumbing — no business logic):** **CORS** (`Baya.WebFramework/ServiceConfiguration/ CorsServiceExtension.cs` → `AddCorsPolicies`, policy `BalinyaarWebClient` from `Cors:AllowedOrigins`, default diff --git a/dev/shared-working-context/backend/handoff/after-refinement-phase-1.md b/dev/shared-working-context/backend/handoff/after-refinement-phase-1.md new file mode 100644 index 0000000..fede4c4 --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-refinement-phase-1.md @@ -0,0 +1,46 @@ +# Handoff — after refinement-phase-1 (Database: local-dev story, demo seed & migration hygiene) + +**Date:** 2026-07-13 · **Track:** backend (+ DB) · **Unlocks:** real search/discovery/booking data for +[Refinement Phase 4](../../../post-phase/refinement/refinement-phase-4-frontend-de-mock.md). + +## What the frontend can now do +- **A fresh Development DB is a populated marketplace, not an empty shell.** On boot (Development only) the API + now seeds a coherent demo world *after* the reference/lookup seeds. Every real-path discovery/search/booking + screen has data on the real path — no frontend change required to see it. +- **Log in as real demo accounts** (phone-OTP flow; read the code from the server console or + `GET /api/v1/dev/last_otp/{phone}`). The accounts already hold the right role, so the role router lands them + on the correct shell: + + | Phone | Role | Who | State | + | --- | --- | --- | --- | + | `09120000001` | nurse | زهرا عزیزی (female) | **verified**, accepting, 3 variants, whole-city + 2 districts | + | `09120000002` | nurse | علی کریمی (male) | **verified**, accepting, 2 variants, 3 districts | + | `09120000003` | nurse | مریم احمدی (female) | **unverified** (pending) — never surfaces in search | + | `09120000010` | customer | سارا محمدی (female) | 2 patients, 1 Tehran address (coords) | + | `09120000011` | customer | رضا حسینی (male) | 1 patient, 1 Tehran address (coords) | + +- **Search returns real nurses.** `GET /api/v1/search/nurses?service_category_id=1&city_id=101` (Elderly Care in + Tehran) returns the two verified nurses' priced variants. Categories offered only by the unverified nurse + (Infant Care, `service_category_id=3`) return an empty page — the `is_searchable` invariant holds. +- **Trust badges are real.** `GET /api/v1/nurses/{id}/trust_badge` returns `isVerified=true` + credential types + for the verified nurses, `false` for the unverified one. +- **The variant builder's required-option step renders on the real path.** The demo seeder adds one + cross-category **required** option group — شیفت / *Shift Type* (`Daytime`/`Night`/`Live-in`) — so + `GET /api/v1/catalog/...` option-group reads are non-empty in Development (the "categories but no option + groups" data gap). This group is **Development-only demo data**, not a production catalog decision. + +## What did NOT change (important) +- **No `USE_*_MOCK` flag was flipped** — de-mocking the client is still [Phase 4](../../../post-phase/refinement/refinement-phase-4-frontend-de-mock.md). + This phase only makes the *backend* real path return data. +- **No new endpoints, no contract changes, no new migration.** The 17 migrations are unchanged and current; + the reference `HasData` seeds are untouched. The demo seeder writes through the existing entities/handlers. +- **Nothing runs in Production/Staging.** The seeder is gated on `IsDevelopment()`. + +## How to reset / re-seed +- `docker compose down -v` (wipe the DB volume) then `dotnet run` → migrate + reference seed + demo seed from + scratch. Re-running `dotnet run` against an already-seeded DB is a **no-op** (guarded on each persona's phone). + +## Gotchas +- **Money is IRR Rials** (e.g. per-24h live-in = `3500000`); on the wire variant prices are digit strings. +- The unverified nurse **has** a variant and a covered area but `is_verified=0`, so the search maintainer + computes `is_searchable=0`. Do not treat "has a variant" as "is discoverable". diff --git a/dev/shared-working-context/reports/refinement-phase-1-report.md b/dev/shared-working-context/reports/refinement-phase-1-report.md new file mode 100644 index 0000000..770d887 --- /dev/null +++ b/dev/shared-working-context/reports/refinement-phase-1-report.md @@ -0,0 +1,92 @@ +# Refinement Phase 1 — Database: local-dev story, demo seed & migration hygiene — Report (2026-07-13) + +## What was built +Turned a freshly-migrated database from an empty shell (reference/lookup rows + one admin + one gateway) into +a **populated demo marketplace**, so every real-path discovery/search/booking screen has data. **No new +migration, no new endpoint, no contract change, and the reference `HasData` seeds are untouched** — the demo +seeder writes *alongside* them through the existing entities and the real search maintainer. + +- **Development-gated demo seeder (§3.2).** New `Persistence/Services/Seeding/DemoWorldSeeder.cs` (scoped, + registered in `AddPersistenceServices`) + `DemoWorldDefinitions.cs` (the static, deterministic persona + data). Invoked from `Program.cs` **only under `app.Environment.IsDevelopment()`**, after + `SeedDefaultUsersAsync` + `SeedPaymentGatewaysAsync`, via the new `SeedDemoWorldAsync()` extension. It + creates: + - **3 nurses** (phone users + `nurse` role via `IAppUserManager`, each with a `nurse_profiles` row): + two **fully verified** (`is_verified` flipped through the guarded `MarkVerified()`, a `nurse_verifications` + row `approved`, credentials, a primary bank account with `matched_national_id=1`), one **unverified** + (`pending`, no credentials/bank). Each verified nurse gets **2–3 `nurse_service_variants`** across price + units (IRR `BIGINT`) + **`nurse_service_areas`** in Tehran (one whole-city `district_id=NULL`, some + specific districts). + - **2 customers** (phone users + `customer` role) each with a `customer_profiles` row, **1–2 `patients`** + (with gender for same-gender matching + encrypted `initial_medical_notes`), and a **`customer_addresses`** + row with coordinates. + - **One cross-category, required option group** — شیفت / *Shift Type* (`Daytime`/`Night`/`Live-in`) — so the + variant builder's required-option step renders on the real path in Development (the "categories but no + option groups" data gap). Each variant answers it; the `OptionSetHash` is computed the same way the real + `CreateVariant` handler does. + - **The search index is driven through the real `ISearchIndexMaintainer.RebuildAsync`** (never hand-inserted) + — the single place `is_searchable` is computed, so the seeded world is identical to one built by real usage. +- **Idempotency (§5).** Every persona is guarded on its **phone number** (`GetUserByPhoneNumber`); the option + group on its name. Re-running the seeder on an already-seeded DB is a no-op (it still re-derives the search + index, which is itself idempotent). +- **Local DB story finished (§3.1).** Phase 0 already delivered `docker-compose.yml` (SQL Server 2022 on + 1433), placeholder `appsettings*.json` connection strings, the `UserSecretsId`, and boot-time + `MigrateAsync`. This phase **verified** those and **documented the explicit `dotnet ef database update` path** + (for the migrations-off-boot / deploy direction Phase 7 formalises) plus the demo-seed reset flow in the + RUNBOOK. +- **Migration hygiene (§3.3).** Confirmed the 17 migrations are current and there are no pending model changes + (the demo seeder adds runtime data, not schema — it changed no entity config and no migration). The three + promised-but-missing forward-dep FKs and the boot-migrate multi-instance race are **explicitly out of scope** + (Phase 6 / Phase 7 respectively), as the phase directs. + +## The exact demo world (for the frontend de-mock phase to log in as) +| Phone | Role | Who | Gender | State | +| --- | --- | --- | --- | --- | +| `09120000001` | nurse | زهرا عزیزی | female | **verified**, accepting · variants: Elderly per-24h `3500000`, Elderly per-hour `250000`, Post-Surgery per-day `2000000` · areas: whole-city Tehran + منطقه ۱ + منطقه ۳ | +| `09120000002` | nurse | علی کریمی | male | **verified**, accepting · variants: Chronic per-day `1800000`, Post-Surgery per-24h `3200000` · areas: منطقه ۳/۶/۱۲ | +| `09120000003` | nurse | مریم احمدی | female | **unverified** (pending) · Infant per-session `800000` · منطقه ۲ — never in search | +| `09120000010` | customer | سارا محمدی | female | patients: حسن (male), فاطمه (female) · address in منطقه ۳ (coords) | +| `09120000011` | customer | رضا حسینی | male | patient: امیرعلی (male, infant) · address in منطقه ۶ (coords) | + +Tehran `city_id = 101`; districts are `1000+n`. Category ids: Elderly `1`, Post-Surgery `2`, Infant `3`, +Chronic `4`. + +## What is now testable (and exactly how) +- **Automated (in the suite, +3 tests → 372 total):** `Baya.Test.Api/DemoWorldSeederTests` runs the seeder + through real DI over the SQLite harness and asserts, over the real HTTP pipeline: + 1. `GET /api/v1/search/nurses?service_category_id=1&city_id=101` (Elderly, verified nurses) → `total > 0`; + `service_category_id=3` (Infant, only the unverified nurse) → `total == 0`. + 2. The verified nurse's `trust_badge` → `isVerified=true` with non-empty `credentialTypes`; the unverified + nurse's → `false`. + 3. Running the seeder **twice** leaves exactly `Nurses.Length` / `Customers.Length` rows (idempotent). +- **Manual (the §7 proof, requires a reachable SQL Server):** wipe + `dotnet run` (Development) → the console + logs `Demo world seeded: 3 nurse(s), 2 customer(s)…`; hit the two search routes above and the trust-badge + routes in Swagger; re-run → `Demo world already seeded — no-op`. *(Not runnable in this environment: the SQL + Server on `localhost:1433` here rejects the dev `sa` credential, so the automated HTTP-level tests above are + the reproducible proof.)* + +## What is mocked / waiting on a real service +- **None introduced.** The seeder goes through the real handlers/maintainer where an invariant is at stake + (verification flip, search reindex, `OptionSetHash`, encrypted-PII converters, `iban_hash`), so no seam and + no `mocks-registry.md` entry. The bank account's `matched_national_id` is set directly to model a completed + استعلام شبا inquiry (the mock `IBankAccountOwnershipVerifier` isn't invoked from the seeder). + +## Contracts +- **None produced / changed.** No new route or shape; `swagger.v1.json` not regenerated. + +## Docs updated +- `server/CLAUDE.md` — Persistence section notes the Development demo seeder and what it creates; Startup + wiring notes the Development-only `SeedDemoWorldAsync()`. +- `dev/post-phase/refinement/RUNBOOK.md` — added the demo-seed description, the demo-account table, the explicit + `dotnet ef database update` path, and the reset/re-seed flow. +- Handoff: `backend/handoff/after-refinement-phase-1.md`. + +## Follow-ups for later phases +- **Phase 4** — flip the client `USE_*_MOCK` flags; log in as the demo accounts above and de-mock home/search/ + booking against this real data. +- **Phase 6** — the additive forward-dep FKs (`refunds.ticket_id`, `nurse_clawbacks.*_payout_id`, + `invoices.partner_center_id`) + money-correctness; *not* in this phase. +- **Phase 7** — split boot-time `MigrateAsync` out for the multi-instance / least-privilege deploy path. +- **Optional later:** a couple of confirmed bookings + sessions for the demo pairs (ids 5001–5005) so the + post-payment screens demo without running the funnel — deliberately deferred (the cleaner demo is to create + bookings by running the real funnel once Phase 4 lands). diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 1e7dc6a..7008b3e 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -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/Config/` implementing `IEntityTypeConfiguration`. - 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`. --- diff --git a/server/src/API/Baya.Web.Api/Program.cs b/server/src/API/Baya.Web.Api/Program.cs index 07f69a7..affa643 100644 --- a/server/src/API/Baya.Web.Api/Program.cs +++ b/server/src/API/Baya.Web.Api/Program.cs @@ -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()) diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Baya.Infrastructure.Persistence.csproj b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Baya.Infrastructure.Persistence.csproj index 65b602c..161b0e3 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Baya.Infrastructure.Persistence.csproj +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Baya.Infrastructure.Persistence.csproj @@ -15,6 +15,7 @@ + diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs index 2f8d9b1..adafb77 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs @@ -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(); + return services; } @@ -118,4 +123,18 @@ public static class ServiceCollectionExtensions await context.SaveChangesAsync(); } + + /// + /// Seeds a coherent Development-only 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 + /// IsDevelopment(): seeding fake accounts into a real DB would be a trust/data-integrity disaster. + /// + public static async Task SeedDemoWorldAsync(this WebApplication app) + { + await using var scope = app.Services.CreateAsyncScope(); + var seeder = scope.ServiceProvider.GetRequiredService(); + await seeder.SeedAsync(CancellationToken.None); + } } \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldDefinitions.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldDefinitions.cs new file mode 100644 index 0000000..ab09e69 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldDefinitions.cs @@ -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; + +/// +/// The static, deterministic description of the Development demo marketplace the +/// 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. +/// +/// All money is IRR Rials as an integer (no Toman, no float). Category ids are the fixed catalog seed +/// ids (1…5); the Tehran city id and district ids are the fixed geography seed ids. +/// +/// +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; + + /// 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. + 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); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldSeeder.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldSeeder.cs new file mode 100644 index 0000000..29fc360 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldSeeder.cs @@ -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; + +/// +/// Development-only seeder that turns a freshly-migrated database (reference/lookup rows + one admin + one +/// gateway) into a populated demo marketplace: 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. +/// +/// It seeds the causes (verification flip, active variants, covered areas) and lets the real +/// compute the nurse_search_index — never hand-inserting index +/// rows — so the seeded world is identical to one built by real usage and the is_searchable 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. +/// +/// +internal sealed class DemoWorldSeeder( + ApplicationDbContext db, + IAppUserManager userManager, + ISearchIndexMaintainer searchIndex, + IFieldEncryptor encryptor, + IDateTimeProvider clock, + ILogger 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 ValueIdByCode)> EnsureShiftTypeGroupAsync( + CancellationToken cancellationToken) + { + var group = await db.Set() + .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().Add(group); + await db.SaveChangesAsync(cancellationToken); + } + + var valueIdByCode = group.Values.ToDictionary(v => v.NameEn, v => v.Id); + return (group.Id, valueIdByCode); + } + + /// true if the persona was newly created; false if it already existed. + private async Task EnsureNurseAsync( + NursePersona persona, + long shiftGroupId, + IReadOnlyDictionary 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().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().Add(verification); + + if (persona.IsVerified) + profile.MarkVerified(); + + foreach (var credential in persona.Credentials) + db.Set().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().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().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 + { + new() { OptionGroupId = shiftGroupId, OptionValueId = shiftValueId } + } + }); + } + + foreach (var area in persona.Areas) + db.Set().Add(new NurseServiceArea + { + NurseId = profile.Id, + CityId = area.CityId, + DistrictId = area.DistrictId, + IsActive = true + }); + + await db.SaveChangesAsync(cancellationToken); + return true; + } + + private async Task 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().Add(profile); + await db.SaveChangesAsync(cancellationToken); // assigns profile.Id used by the FK-only child rows + + foreach (var patient in persona.Patients) + db.Set().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().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 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().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; + } +} diff --git a/server/src/Tests/Baya.Test.Api/DemoWorldSeederTests.cs b/server/src/Tests/Baya.Test.Api/DemoWorldSeederTests.cs new file mode 100644 index 0000000..5c9c8a1 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/DemoWorldSeederTests.cs @@ -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; + +/// +/// 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). +/// +public class DemoWorldSeederTests(BayaApiFactory factory) : IClassFixture +{ + 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(); + 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(); + verifiedNurseId = await db.Set().Where(p => p.IsVerified).Select(p => p.Id).FirstAsync(); + unverifiedNurseId = await db.Set().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(); + + Assert.Equal(DemoWorldDefinitions.Nurses.Length, await db.Set().CountAsync()); + Assert.Equal(DemoWorldDefinitions.Customers.Length, await db.Set().CountAsync()); + } +}