backend phase 3: identity profiles, patients & nurse bank accounts

Add the role-attached identity layer on top of the b2 auth spine: nurse
seller profiles (guarded is_verified, read-only aggregates), thin customer
payer profiles, first-class patients (tenancy-scoped), and nurse payout bank
accounts hardened with an iban_hash uniqueness guard and an automated استعلام
شبا IBAN-ownership inquiry.

- Four usr tables via one migration (1:1 uniques, UNIQUE(iban_hash), filtered
  UNIQUE(nurse_id) WHERE is_primary=1, guarded is_verified, encrypted PII,
  soft-delete on nurse_profiles)
- 15 CQRS slices + 4 role-scoped controllers; reads projected + paginated,
  IBAN masked (last-4); ownership-inquiry endpoints rate-limited
- New IBankAccountOwnershipVerifier seam (mock deterministic شبا match) +
  per-domain repositories on IUnitOfWork + encrypted-PII value converters
- Activate FluentValidation repo-wide (validators were never registered)
- Handler unit tests + WebApplicationFactory integration tests (76 pass);
  contract identity-profiles.md + swagger snapshot; docs, handoff & report

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
hamid
2026-07-02 12:03:15 +03:30
parent 17a82832ab
commit 39a979b1a7
89 changed files with 6060 additions and 13 deletions
@@ -0,0 +1,69 @@
# Backend phase 3 report — Identity: profiles, patients & nurse bank accounts
## What was built
- **Four domain entities** (`Baya.Domain/Entities/Identity/`): `NurseProfile`, `CustomerProfile`,
`Patient`, `NurseBankAccount`. `NurseProfile.is_verified` is write-guarded (private setter +
`MarkVerified()`/`MarkUnverified()` — only b6 calls it); `is_accepting_bookings` toggled via a domain
method; the search aggregates are read-only.
- **One EF migration** `IdentityProfilesPatientsBankAccounts` (schema `usr`): 1:1 uniques on
`user_id`, `UNIQUE(iban_hash)`, filtered `UNIQUE(nurse_id) WHERE is_primary=1`, soft-delete on
`NurseProfiles`, encrypted PII columns, audit fields. No CUT columns.
- **15 CQRS slices** under `Features/Identity/{Commands|Queries}/` + 4 `sealed : BaseController`
controllers (`NurseProfilesController`, `CustomerProfilesController`, `PatientsController`,
`NurseBankAccountsController`). Reads project to DTOs; lists paginate; the ownership-inquiry endpoints
are rate-limited (`sensitive` policy).
- **New seam `IBankAccountOwnershipVerifier`** (Application `Contracts/Common`) + mock
`MockBankAccountOwnershipVerifier` (CrossCutting), registered in `AddCrossCuttingSeams`, config-selected.
- **Persistence:** four per-domain repositories on `IUnitOfWork` (`NurseProfileRepository`,
`CustomerProfileRepository`, `PatientRepository`, `NurseBankAccountRepository`); encrypted-PII value
converters for the new columns wired in `ApplicationDbContext.OnModelCreating`; an atomic
`SetPrimaryAsync` (clear-then-set in one transaction) so the single-primary index never trips.
- **Infra fix:** `AddApplicationServices` now registers every `AbstractValidator<T>` as `IValidator<T>`
so the pre-existing `ValidateCommandBehavior` and `ModelStateValidationAttribute` filter actually
validate (they had **no** validators registered before this phase — validation was silently inert).
## What is now testable, and exactly how (mirrors the phase §7)
Log in as a nurse and a customer (b2 OTP flow), **refreshing the token after `select_role`** so the
role claim is present. Then:
1. **Nurse profile**`POST api/v1/nurse_profiles/upsert` → row created `is_verified=0`,
`is_accepting_bookings=0`; `GET …/me` shows aggregates at 0. No path sets `is_verified`.
2. **Accepting-bookings**`POST …/set_accepting_bookings` flips it; verified untouched.
3. **Customer profile**`POST api/v1/customer_profiles/upsert` with emergency contact → `GET …/me`
round-trips it through the encrypted column.
4. **Patient CRUD**`create` (gender required) / `list` / `get/{id}` / `update/{id}` / `archive/{id}`.
5. **Tenancy** — customer B calling `get`/`update` on customer A's patient id → **404**.
6. **Bank account + inquiry**`POST api/v1/nurse_bank_accounts/add` (normal IBAN) → `matched_national_id=true`,
vendor ref recorded; `list` shows the IBAN **masked**.
7. **Mismatch** — add the mismatch IBAN → `matched_national_id=false`.
8. **Duplicate IBAN** — re-add the same IBAN → clean `400` via `iban_hash` uniqueness.
9. **Primary flip** — add a 2nd account, `set_primary/{id2}` → account 2 primary, account 1 not; never two primaries.
Automated coverage: 15 handler unit tests (NSubstitute) covering profile upsert, role forbidden, patient
CRUD + cross-customer 404, bank add match/mismatch/duplicate + set-primary flip/not-owned; 13
`WebApplicationFactory` integration tests (one+ per controller: happy path, 401, validation 400, tenancy
404, mask, duplicate, primary flip, mismatch). `dotnet build` clean (0 new code warnings); `dotnet test`
green (75 pass).
## What is mocked / waiting on a real service
- **`IBankAccountOwnershipVerifier` (🟡)** — deterministic fake استعلام شبا. Make-it-real steps in
`reports/mocks-registry.md`. Reused seams: `IFieldEncryptor`, `ICurrentUser`, `IDateTimeProvider`.
## Contracts produced / consumed
- **Produced:** `dev/contracts/domains/identity-profiles.md`; `dev/contracts/openapi/swagger.v1.json`
refreshed (adds all nurse-profile / customer-profile / patient / bank-account paths).
- **Consumed:** b2 auth (login/roles), b0 seams (`IFieldEncryptor`/`ICurrentUser`/`IDateTimeProvider`),
b1 `IPlatformConfig` (available; not needed this phase).
## Follow-ups for later phases
- **b4:** `customer_addresses` + `nurse_service_areas` (need geography + geocoder) — deferred here.
- **b6:** the `is_verified` flip (verification-confirm transaction); Shahkar/KYC populate `national_id`;
the `bank_account_verification` step couples to `NurseBankAccounts`.
- **b13:** first-payout gate on `matched_national_id = true`.
- **b9/b14:** recompute `average_rating`/`total_reviews`/`total_completed_bookings` (read-only here).
- **Chain-wide:** validators are now active — new phases must ensure route-supplied ids aren't validated
in the body command, and can rely on FluentValidation for input rejection.
## Decisions taken (flagged for confirmation)
- A thin `customer_profiles` row is **auto-provisioned** on a customer's first patient (so patient
registration needs no separate profile step). Recorded in `product/data-model/01-identity-and-access.md`.
- IBAN is returned **masked (last-4)** on every read; first account added is primary by default.
@@ -26,7 +26,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| `IShahkarVerifier` | backend-phase-6 | Phone↔national-id match — fake pass | _tbd_ | Real Shahkar/KYC vendor; persist `external_response_json` | 🔴 |
| `IIdentityKycProvider` | backend-phase-6 | National-ID + liveness — fake pass | _tbd_ | Finnotech/U-ID/Jibbit/Verify liveness+OCR | 🔴 |
| `ICredentialVerifier` | backend-phase-6 | MoH/INO/criminal-record — manual/fake | _tbd_ | Manual admin today; API when a portal appears (`verification_method=api`) | 🔴 |
| `IBankAccountOwnershipVerifier` | backend-phase-3/6 | استعلام شبا IBANnational-id — fake match | _tbd_ | Real KYC vendor; store `ownership_vendor_ref` | 🔴 |
| `IBankAccountOwnershipVerifier` | backend-phase-3 | استعلام شبا IBAN-owner ↔ national-id inquiry — `MockBankAccountOwnershipVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic fake: every IBAN matches (`matched_national_id=true`, echoes a holder name + `MOCK-SHEBA-{sha}` vendor ref) except the configured mismatch IBAN which returns `false`; registered singleton in `AddCrossCuttingSeams`. No real bank/KYC call, no money moves | `Seams:BankOwnership:MismatchIban` (default `IR000000000000000000000000`), `Seams:BankOwnership:MatchedHolderName`, `Seams:BankOwnership:MismatchHolderName` | 1) pick a Finnotech / banking-bridge استعلام شبا provider, add its client package to `Directory.Packages.props`; 2) add `Seams:BankOwnership:{ApiKey,BaseUrl}` options; 3) implement `VerifyOwnershipAsync(iban, nurseNationalId)` against the real Sheba-owner inquiry, mapping to `OwnershipInquiryResult`; 4) persist the real `ownership_vendor_ref` (+ raw response if a column is added); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match/mismatch + that the b13 first-payout gate honours `matched_national_id=true` | 🟡 |
| `IGeocoder` | backend-phase-4 | Address→lat/lng — echo/static | _tbd_ | Neshan/Google geocoding | 🔴 |
| `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoice — leaves ref pending | _tbd_ | Real مودیان submission → 22-digit ref | 🔴 |
| `IReviewModerationService` | backend-phase-14 | AI moderation — keyword/pass-through | _tbd_ | Real classifier/LLM endpoint | 🔴 |
@@ -36,3 +36,13 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
> Exact config keys and file paths get filled in by the phase that builds each seam. Keep the
> "Make it real →" column actionable enough that a developer can pick up any single row and ship it.
## Frontend client-side mocks (not backend DI seams)
These are in-browser mocks behind a `services/{domain}` interface, selected by a config flag. They exist so
the frontend can build before the backend phase merges, and swap to the real HTTP client in one line.
| Seam (interface) | File | What it fakes | Config flag | Make it real → | Status |
| --- | --- | --- | --- | --- | --- |
| `PatientsApi` | `client/src/services/patients/apis/mockApi.ts` | In-memory patient list/create | `USE_PATIENTS_MOCK` (`services/patients/constants.ts`) | Publish `/patients` endpoints, set flag `false` | 🟡 |
| `AuthApi` | `client/src/services/auth/apis/mockApi.ts` (`authMockApi`) | Phone-OTP login offline: `requestOtp``{otpSent,resendAvailableInSeconds:120}`; `verifyOtp` accepts dev code **`123456`** and locks after 3 wrong tries (`otp_locked`); `getMe`/`selectRole`/`refresh` from a `MOCK_SCENARIO` toggle (`customer`/`nurse_unverified`/`no_role`) to exercise all router branches | `USE_AUTH_MOCK` (`services/auth/constants.ts`, default **false** — b2 is live) + `MOCK_SCENARIO` in `mockApi.ts` | The real `authClientApi` is already wired to the live b2 routes; set `USE_AUTH_MOCK = false` (already the default) — no hook/screen change | 🟢 real by default, 🟡 mock available |