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,87 @@
# Contract — Identity profiles, patients & nurse bank accounts (backend phase b3)
> Role-attached identity data on top of the b2 auth spine: the nurse seller profile, the customer payer
> profile, the customer's patients, and the nurse's payout bank accounts. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md).
**Status:** live as of backend-phase-b3 · **Frontend consumer:** frontend-phase-f2-b3
All endpoints require a **Bearer access token** (`[Authorize]`); unauthenticated calls return `401`.
Role scoping is enforced in the handler and returns `403` when the caller lacks the required role — and
**role claims are baked into the access token at mint time**, so a client must refresh (or re-login)
after `me/select_role` before these endpoints see the new role. Request bodies are camelCase JSON; URL
segments are snake_case; responses use the standard `OperationResult``ApiResult` envelope (payload in
`data`).
## Enums used
- `gender`: `male` | `female` — load-bearing for same-gender caregiver matching; required on a patient.
- `blood_type`: free-form short string (e.g. `O+`, `AB-`), nullable — not a fixed enum at MVP.
## Shared shapes
- `NurseProfileDto`: `id` (int64), `bio` (string), `yearsOfExperience` (int), `educationLevel` (string),
`educationField` (string), `specializationsJson` (string — raw JSON array), `isVerified` (bool,
**read-only** — always false until b6 verification), `isAcceptingBookings` (bool),
`averageRating` (decimal), `totalReviews` (int), `totalCompletedBookings` (int) — the last three are
**read-only aggregates**, 0 until reviews/bookings phases.
- `CustomerProfileDto`: `id` (int64), `defaultEmergencyContactName` (string), `defaultEmergencyContactPhone`
(string) — decrypted and returned **in full** to the owning customer (self).
- `PatientDto`: `id` (int64), `displayName`, `firstName`, `lastName` (strings), `birthDate` (date
`YYYY-MM-DD`), `gender` (`male`/`female`), `bloodType` (string, nullable), `initialMedicalNotes`
(string — decrypted, owner-only), `isActive` (bool).
- `NurseBankAccountDto`: `id` (int64), `bankName` (string), `ibanMasked` (string — **last 4 only**, e.g.
`••••3456`; the full IBAN is never returned), `isPrimary` (bool), `isVerified` (bool),
`matchedNationalId` (bool **nullable** — null until the ownership inquiry runs).
## Endpoints
### Nurse profile — role `nurse`
- `POST api/v1/nurse_profiles/upsert` — create/update own profile. Body: `{ bio, yearsOfExperience,
educationLevel, educationField, specializationsJson }`. Returns `NurseProfileDto`. **Never accepts
`isVerified` or the aggregates.** `400` if `yearsOfExperience` ∉ [0,80]; `403` non-nurse.
- `POST api/v1/nurse_profiles/set_accepting_bookings` — body `{ accepting: bool }`. Empty `200` on
success; `404` if no profile yet. Never touches `isVerified`.
- `GET api/v1/nurse_profiles/me` — returns `NurseProfileDto`; `404` if none.
### Customer profile — role `customer`
- `POST api/v1/customer_profiles/upsert` — body `{ defaultEmergencyContactName, defaultEmergencyContactPhone }`
(phone stored **encrypted**). Returns `CustomerProfileDto`. `400` invalid phone / empty name; `403` non-customer.
- `GET api/v1/customer_profiles/me` — returns `CustomerProfileDto`; `404` if none.
### Patients — role `customer` (tenancy-scoped to the caller)
- `POST api/v1/patients/create` — body `{ displayName, firstName, lastName, birthDate, gender, bloodType,
initialMedicalNotes }`. `customerId` is derived from the caller (a thin customer profile is
auto-provisioned on first patient) — **never taken from the body**. Returns `PatientDto`. `400` missing/invalid
`gender` or future `birthDate`.
- `GET api/v1/patients/list?page=&pageSize=` — paginated (`page` 1-based, `pageSize` ≤100, default 50).
Returns `PagedResult<PatientDto>` (`items`, `total`, `page`, `pageSize`) of the caller's **own** patients only.
- `GET api/v1/patients/get/{id}` — returns `PatientDto`; `404` if not owned (existence not leaked).
- `POST api/v1/patients/update/{id}` — body as create (id from the route). Returns `PatientDto`; `404` if not owned.
- `POST api/v1/patients/archive/{id}` — soft-archive (`isActive=false`, not a delete). Empty `200`; `404` if not owned.
### Nurse bank accounts — role `nurse` (tenancy-scoped)
- `POST api/v1/nurse_bank_accounts/add` — **rate-limited**. Body `{ bankName, accountHolderName, iban }`
(IBAN `IR`+24 digits; stored encrypted). Runs the استعلام شبا ownership inquiry and returns
`NurseBankAccountDto` with `matchedNationalId` set. Becomes primary if it is the nurse's first account.
`400` invalid IBAN, **duplicate IBAN** (via `iban_hash` uniqueness — a clean failure, not a 500), or no nurse profile.
- `POST api/v1/nurse_bank_accounts/set_primary/{id}` — makes the account primary and clears the prior
primary atomically (the filtered single-primary index never trips). Empty `200`; `404` if not owned.
- `GET api/v1/nurse_bank_accounts/list` — returns `NurseBankAccountDto[]` with **masked** IBANs.
- `POST api/v1/nurse_bank_accounts/verify_ownership/{id}` — **rate-limited**. Re-runs the ownership
inquiry (idempotent: same input → same vendor ref). Returns the updated `NurseBankAccountDto`; `404` if not owned.
## Side effects & rules the API enforces
- **Guarded `isVerified`** — there is no field or endpoint to set it; a nurse profile is created
unverified and stays so until the b6 verification-confirm transaction.
- **Tenancy** — a customer only ever sees/mutates their own patients; a nurse only their own bank
accounts. Cross-tenant access returns `404` (never leaks existence).
- **IBAN masking** — the full IBAN is never returned; lists/DTOs carry last-4 only. The full value is
encrypted at rest.
- **`matchedNationalId` gates the first payout (b13)** — set here by the (mocked)
`IBankAccountOwnershipVerifier`, not by admin eyeballing; `null` until the inquiry has run.
- **Deferred:** saved service addresses & nurse coverage areas (b4); customer national-ID KYC (not
collected, never gates browsing/booking).
## Changelog
- b3 — initial contract (nurse/customer profiles, patients, nurse bank accounts + ownership inquiry).
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,28 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
- **Notes for frontend:** <anything load-bearing> - **Notes for frontend:** <anything load-bearing>
--> -->
## backend-phase-3 — Identity: profiles, patients & nurse bank accounts — 2026-07-02
- **Shipped:** four `usr` tables via one migration (`IdentityProfilesPatientsBankAccounts`) —
`NurseProfiles` (1:1 `Users`; guarded `is_verified` **no public setter**; read-only aggregates;
soft-delete), `CustomerProfiles` (thin payer; enc emergency contact), `Patients` (care recipient,
tenancy-scoped; `is_active` archive; enc `initial_medical_notes`), `NurseBankAccounts` (enc `iban` +
`UNIQUE(iban_hash)` + filtered `UNIQUE(nurse_id) WHERE is_primary=1`; استعلام شبا inquiry fields);
15 CQRS slices across 4 controllers (`nurse_profiles`, `customer_profiles`, `patients`,
`nurse_bank_accounts`); new **`IBankAccountOwnershipVerifier`** seam (mock = deterministic شبا match);
per-domain repositories on `IUnitOfWork`; enc value converters for the new PII columns. Also
**activated FluentValidation** repo-wide (`AddApplicationServices` now registers every
`AbstractValidator<T>` — the `ValidateCommandBehavior`/model-state filter were previously starved).
- **Contracts:** dev/contracts/domains/identity-profiles.md + openapi snapshot refreshed (yes — new
nurse/customer/patient/bank paths).
- **Mocked:** `IBankAccountOwnershipVerifier` → 🟡 (see reports/mocks-registry.md).
- **Gate:** build clean (0 new code warnings) / tests green (75 pass: +13 `Baya.Test.Api` integration,
+15 handler unit tests). Migration applied to the dev DB on startup; swagger exposes all b3 paths.
- **Handoff:** backend/handoff/after-backend-phase-3.md
- **Notes for frontend:** role scoping needs the **role claim in the token** — refresh after
`select_role` before calling these. IBAN comes back **masked** (last-4). `isVerified`/aggregates are
read-only. Patient `get/update` of another customer's id → **404**. Addresses/service-areas are
**deferred to b4**.
## backend-phase-2 — Identity: phone-OTP auth, sessions & roles (REST) — 2026-07-02 ## backend-phase-2 — Identity: phone-OTP auth, sessions & roles (REST) — 2026-07-02
- **Shipped:** the six-endpoint REST auth surface (`auth/request_otp`, `auth/verify_otp`, - **Shipped:** the six-endpoint REST auth surface (`auth/request_otp`, `auth/verify_otp`,
`auth/refresh`, `auth/logout`, `me`, `me/select_role`) wrapping the existing JWE/TOTP/RBAC engine; `auth/refresh`, `auth/logout`, `me`, `me/select_role`) wrapping the existing JWE/TOTP/RBAC engine;
@@ -0,0 +1,57 @@
# After backend-phase-3 — profiles, patients & nurse bank accounts are live
On top of the b2 auth spine, the *people behind the accounts* now exist. A nurse has a seller profile
(that b6 will verify and b5 will hang service variants off), a customer has a payer profile and their
patients, and a nurse has payout bank accounts with an automated IBAN-ownership inquiry. Contract:
[`dev/contracts/domains/identity-profiles.md`](../../../contracts/domains/identity-profiles.md); machine
schema: `dev/contracts/openapi/swagger.v1.json` (refreshed).
## What the frontend (f2-b3) can now build
- **Nurse profile bootstrap:** `POST api/v1/nurse_profiles/upsert` (bio, experience, education,
`specializationsJson`), `GET api/v1/nurse_profiles/me`, and the pause/resume toggle
`POST api/v1/nurse_profiles/set_accepting_bookings`. `isVerified` and the rating/booking aggregates are
**read-only** (verification is b6) — render them, never send them.
- **Customer profile:** `POST api/v1/customer_profiles/upsert` (emergency contact) + `GET …/me`.
- **"Who is care for" (patients):** `create` / `list` (paginated) / `get/{id}` / `update/{id}` /
`archive/{id}` under `api/v1/patients`. `gender` (`male`/`female`) is **required**. A customer only ever
sees/edits their own patients — someone else's id returns **404**.
- **Nurse bank-account settings:** `add` (returns the account with `matchedNationalId` set by the شبا
inquiry), `list` (IBAN **masked**, last-4), `set_primary/{id}`, and `verify_ownership/{id}` to re-run
the inquiry.
## Rules baked into the API (don't fight them client-side)
- **Refresh after `select_role`.** These endpoints authorize on the **role claim in the access token**;
the token minted before role selection lacks it. Login → `select_role`**refresh** (or re-login) →
then call profile/patient/bank endpoints. Otherwise you get `403`.
- **Guarded verification** — no field/endpoint sets `isVerified`; a nurse is not bookable until b6.
- **Tenancy** — patients and bank accounts are strictly owner-scoped; cross-tenant reads/writes are `404`.
- **IBAN is masked on the wire** (last-4 only); the full value is encrypted at rest.
- **`matchedNationalId`** is the money-mule-prevention gate for the first payout (enforced in b13). It is
`null` until the inquiry runs; `add` runs it automatically. The bank rail is **mocked** at MVP.
- Duplicate IBAN → a clean `400` (via `iban_hash` uniqueness), not a server error.
## What's mocked
- **IBAN ownership (`IBankAccountOwnershipVerifier` → 🟡).** Deterministic fake استعلام شبا: every IBAN
matches except the configured mismatch IBAN (`Seams:BankOwnership:MismatchIban`, default
`IR000000000000000000000000`) which returns `matchedNationalId=false`. No real bank/KYC call.
## Schema / migration
Migration **`20260702042131_IdentityProfilesPatientsBankAccounts`** (applied to the dev DB on startup):
`usr.NurseProfiles`, `usr.CustomerProfiles`, `usr.Patients`, `usr.NurseBankAccounts` — with the 1:1
uniques, `UNIQUE(iban_hash)`, filtered single-primary index, guarded `is_verified`, encrypted PII columns
(`iban`, `account_holder_name`, emergency contacts, `initial_medical_notes`), and soft-delete on
`NurseProfiles`. None of the CUT columns (`verification_status`, `response_rate`, … ,
`customer_profiles.national_id_verified_at`) exist.
## Deferred to later phases (do not build against these yet)
- **Addresses & nurse service areas → b4** (need province/city/district + geocoder).
- **`is_verified` flip → b6** (verification pipeline).
- **Payout gating on `matched_national_id` → b13.**
- **Aggregate recompute (rating/reviews/completed) → b9/b14.**
- **Customer national-ID KYC** — intentionally not collected; never gate browsing/booking on it.
## Note for the whole backend chain
FluentValidation was previously inert (no validators registered). b3 activates it in
`AddApplicationServices` — every `AbstractValidator<T>` now runs via `ValidateCommandBehavior` and the
`ModelStateValidationAttribute` controller filter. **Consequence:** for route-supplied ids, don't add a
body validator rule on that id (e.g. `patients/update/{id}` validates the body, whose `Id` is 0).
@@ -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` | 🔴 | | `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 | 🔴 | | `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`) | 🔴 | | `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 | 🔴 | | `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 | 🔴 | | `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 | 🔴 | | `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 > 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. > "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 |
@@ -48,6 +48,8 @@
<p><strong>Relations:</strong> 1:1 → <code>users</code>, <code>nurse_verifications</code>; 1:N → <code>nurse_service_variants</code>, <code>nurse_service_areas</code>, <code>nurse_bank_accounts</code>, <code>nurse_credentials</code>, <code>bookings</code>, <code>nurse_payouts</code>, <code>nurse_clawbacks</code>; N:1 → <code>partner_centers</code>.</p> <p><strong>Relations:</strong> 1:1 → <code>users</code>, <code>nurse_verifications</code>; 1:N → <code>nurse_service_variants</code>, <code>nurse_service_areas</code>, <code>nurse_bank_accounts</code>, <code>nurse_credentials</code>, <code>bookings</code>, <code>nurse_payouts</code>, <code>nurse_clawbacks</code>; N:1 → <code>partner_centers</code>.</p>
<h3 id="customer_profiles-core"><code>customer_profiles</code> [CORE] <a class="anchor" href="#customer_profiles-core" aria-hidden="true">#</a></h3> <h3 id="customer_profiles-core"><code>customer_profiles</code> [CORE] <a class="anchor" href="#customer_profiles-core" aria-hidden="true">#</a></h3>
<p><strong>Role:</strong> Lightweight extension for customers. <strong>Why intentionally thin:</strong> most customer reality lives in their <code>patients</code>, <code>customer_addresses</code>, and <code>bookings</code>. KYC for customers is deferred. Unchanged: <code>id</code>, <code>user_id</code> (unique), <code>default_emergency_contact_name</code>/<code>_phone</code> (enc), <code>created_at</code>, <code>updated_at</code>. <strong>CUT for MVP:</strong> <code>national_id_verified_at</code> (anti-fraud customer KYC — add when actually built). <strong>Relations:</strong> 1:1 → <code>users</code>; 1:N → <code>patients</code>, <code>customer_addresses</code>, <code>booking_requests</code>, <code>bookings</code>.</p> <p><strong>Role:</strong> Lightweight extension for customers. <strong>Why intentionally thin:</strong> most customer reality lives in their <code>patients</code>, <code>customer_addresses</code>, and <code>bookings</code>. KYC for customers is deferred. Unchanged: <code>id</code>, <code>user_id</code> (unique), <code>default_emergency_contact_name</code>/<code>_phone</code> (enc), <code>created_at</code>, <code>updated_at</code>. <strong>CUT for MVP:</strong> <code>national_id_verified_at</code> (anti-fraud customer KYC — add when actually built). <strong>Relations:</strong> 1:1 → <code>users</code>; 1:N → <code>patients</code>, <code>customer_addresses</code>, <code>booking_requests</code>, <code>bookings</code>.</p>
<blockquote><p><strong>As-built (backend-phase-3):</strong> a thin <code>customer_profiles</code> row is <strong>auto-provisioned</strong> the first time a customer registers a patient, so the customer/patient split works without a separate "create profile" step. The emergency-contact fields are encrypted at rest and returned in full only to the owning customer.</p>
</blockquote>
<h3 id="patients-core"><code>patients</code> [CORE] <a class="anchor" href="#patients-core" aria-hidden="true">#</a></h3> <h3 id="patients-core"><code>patients</code> [CORE] <a class="anchor" href="#patients-core" aria-hidden="true">#</a></h3>
<p><strong>Role:</strong> The person receiving care, <strong>separate from the payer</strong>. <strong>Why:</strong> the payer (adult child, spouse) is usually not the patient (elderly parent, newborn, post-surgical adult); one customer registers many patients, each with its own clinical baseline and longitudinal record. Unchanged: <code>id</code>, <code>customer_id</code>, <code>display_name</code>, <code>first_name</code>, <code>last_name</code>, <code>birth_date</code>, <code>gender</code>, <code>blood_type</code>, <code>initial_medical_notes</code> (enc), <code>is_active</code>, timestamps. <strong>Relations:</strong> N:1 → <code>customer_profiles</code>; 1:N → <code>booking_requests</code>, <code>patient_care_records</code>. <strong>Tenancy invariant:</strong> a <code>booking_request.patient_id</code> must belong to the same <code>customer_id</code>.</p> <p><strong>Role:</strong> The person receiving care, <strong>separate from the payer</strong>. <strong>Why:</strong> the payer (adult child, spouse) is usually not the patient (elderly parent, newborn, post-surgical adult); one customer registers many patients, each with its own clinical baseline and longitudinal record. Unchanged: <code>id</code>, <code>customer_id</code>, <code>display_name</code>, <code>first_name</code>, <code>last_name</code>, <code>birth_date</code>, <code>gender</code>, <code>blood_type</code>, <code>initial_medical_notes</code> (enc), <code>is_active</code>, timestamps. <strong>Relations:</strong> N:1 → <code>customer_profiles</code>; 1:N → <code>booking_requests</code>, <code>patient_care_records</code>. <strong>Tenancy invariant:</strong> a <code>booking_request.patient_id</code> must belong to the same <code>customer_id</code>.</p>
<h3 id="customer_addresses-core"><code>customer_addresses</code> [CORE] <a class="anchor" href="#customer_addresses-core" aria-hidden="true">#</a></h3> <h3 id="customer_addresses-core"><code>customer_addresses</code> [CORE] <a class="anchor" href="#customer_addresses-core" aria-hidden="true">#</a></h3>
@@ -62,6 +64,8 @@
<tr><td><code>ownership_vendor_ref</code></td><td>NVARCHAR(200) NULL</td><td><strong>NEW</strong> — vendor transaction id for audit.</td></tr> <tr><td><code>ownership_vendor_ref</code></td><td>NVARCHAR(200) NULL</td><td><strong>NEW</strong> — vendor transaction id for audit.</td></tr>
</tbody></table></div> </tbody></table></div>
<p>Constraints: <strong>filtered <code>UNIQUE(nurse_id) WHERE is_primary=1</code></strong>; <code>UNIQUE(iban_hash)</code>. <strong>Relations:</strong> N:1 → <code>nurse_profiles</code>; 1:N → <code>nurse_payouts</code>.</p> <p>Constraints: <strong>filtered <code>UNIQUE(nurse_id) WHERE is_primary=1</code></strong>; <code>UNIQUE(iban_hash)</code>. <strong>Relations:</strong> N:1 → <code>nurse_profiles</code>; 1:N → <code>nurse_payouts</code>.</p>
<blockquote><p><strong>As-built (backend-phase-3):</strong> the IBAN is <strong>returned masked (last 4 only)</strong> on every read — the full value is encrypted at rest and never leaves the server. A nurse's <strong>first</strong> bank account becomes primary automatically; <code>set_primary</code> thereafter clears the prior primary and sets the new one in one transaction. <code>matched_national_id</code> starts NULL and is set only by the استعلام شبا ownership inquiry (mocked behind <code>IBankAccountOwnershipVerifier</code> at MVP), which is the money-mule-prevention gate for the first payout (b13).</p>
</blockquote>
<a class="back-to-top" href="#">↑ Back to top</a> <a class="back-to-top" href="#">↑ Back to top</a>
</div></main> </div></main>
</div> </div>
@@ -45,6 +45,10 @@ Fields unchanged from baseline: `id`, `email` (enc, nullable), `phone` (enc, uni
### `customer_profiles` [CORE] ### `customer_profiles` [CORE]
**Role:** Lightweight extension for customers. **Why intentionally thin:** most customer reality lives in their `patients`, `customer_addresses`, and `bookings`. KYC for customers is deferred. Unchanged: `id`, `user_id` (unique), `default_emergency_contact_name`/`_phone` (enc), `created_at`, `updated_at`. **CUT for MVP:** `national_id_verified_at` (anti-fraud customer KYC — add when actually built). **Relations:** 1:1 → `users`; 1:N → `patients`, `customer_addresses`, `booking_requests`, `bookings`. **Role:** Lightweight extension for customers. **Why intentionally thin:** most customer reality lives in their `patients`, `customer_addresses`, and `bookings`. KYC for customers is deferred. Unchanged: `id`, `user_id` (unique), `default_emergency_contact_name`/`_phone` (enc), `created_at`, `updated_at`. **CUT for MVP:** `national_id_verified_at` (anti-fraud customer KYC — add when actually built). **Relations:** 1:1 → `users`; 1:N → `patients`, `customer_addresses`, `booking_requests`, `bookings`.
> **As-built (backend-phase-3):** a thin `customer_profiles` row is **auto-provisioned** the first time a
> customer registers a patient, so the customer/patient split works without a separate "create profile"
> step. The emergency-contact fields are encrypted at rest and returned in full only to the owning customer.
### `patients` [CORE] ### `patients` [CORE]
**Role:** The person receiving care, **separate from the payer**. **Why:** the payer (adult child, spouse) is usually not the patient (elderly parent, newborn, post-surgical adult); one customer registers many patients, each with its own clinical baseline and longitudinal record. Unchanged: `id`, `customer_id`, `display_name`, `first_name`, `last_name`, `birth_date`, `gender`, `blood_type`, `initial_medical_notes` (enc), `is_active`, timestamps. **Relations:** N:1 → `customer_profiles`; 1:N → `booking_requests`, `patient_care_records`. **Tenancy invariant:** a `booking_request.patient_id` must belong to the same `customer_id`. **Role:** The person receiving care, **separate from the payer**. **Why:** the payer (adult child, spouse) is usually not the patient (elderly parent, newborn, post-surgical adult); one customer registers many patients, each with its own clinical baseline and longitudinal record. Unchanged: `id`, `customer_id`, `display_name`, `first_name`, `last_name`, `birth_date`, `gender`, `blood_type`, `initial_medical_notes` (enc), `is_active`, timestamps. **Relations:** N:1 → `customer_profiles`; 1:N → `booking_requests`, `patient_care_records`. **Tenancy invariant:** a `booking_request.patient_id` must belong to the same `customer_id`.
@@ -63,3 +67,9 @@ Fields unchanged from baseline: `id`, `email` (enc, nullable), `phone` (enc, uni
| `ownership_vendor_ref` | NVARCHAR(200) NULL | **NEW** — vendor transaction id for audit. | | `ownership_vendor_ref` | NVARCHAR(200) NULL | **NEW** — vendor transaction id for audit. |
Constraints: **filtered `UNIQUE(nurse_id) WHERE is_primary=1`**; `UNIQUE(iban_hash)`. **Relations:** N:1 → `nurse_profiles`; 1:N → `nurse_payouts`. Constraints: **filtered `UNIQUE(nurse_id) WHERE is_primary=1`**; `UNIQUE(iban_hash)`. **Relations:** N:1 → `nurse_profiles`; 1:N → `nurse_payouts`.
> **As-built (backend-phase-3):** the IBAN is **returned masked (last 4 only)** on every read — the full
> value is encrypted at rest and never leaves the server. A nurse's **first** bank account becomes primary
> automatically; `set_primary` thereafter clears the prior primary and sets the new one in one transaction.
> `matched_national_id` starts NULL and is set only by the استعلام شبا ownership inquiry (mocked behind
> `IBankAccountOwnershipVerifier` at MVP), which is the money-mule-prevention gate for the first payout (b13).
+21 -3
View File
@@ -81,12 +81,12 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
``` ```
src/ src/
├── Core/ ├── Core/
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker) │ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
│ └── Baya.Application Features/ (Commands & Queries; + Identity/Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams + the platform-signal facade contracts), Models/, pipeline behaviors (Common/) │ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + the platform-signal facade contracts + Contracts/Persistence per-domain repositories on IUnitOfWork), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly)
├── Infrastructure/ ├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service) │ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service)
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/) │ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender) + AddCrossCuttingSeams │ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier) + AddCrossCuttingSeams
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net │ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
├── API/ ├── API/
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications), appsettings*.json │ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications), appsettings*.json
@@ -126,6 +126,24 @@ service there too. Other domains call these contracts; they never re-create the
`AuditFieldInterceptor` additionally writes an append-only `audit_logs` row for any `IAuditable` entity `AuditFieldInterceptor` additionally writes an append-only `audit_logs` row for any `IAuditable` entity
(currently `PlatformConfig`) in the same transaction as the change. (currently `PlatformConfig`) in the same transaction as the change.
**Identity profiles, patients & nurse bank accounts (backend-phase-3).** On top of the b2 auth spine,
the `usr` schema gains four role-attached tables: `NurseProfiles` (1:1 with `Users`; guarded
`is_verified` with **no public setter** — flipped only by b6; read-only aggregates), `CustomerProfiles`
(thin payer extension; encrypted emergency contact), `Patients` (care recipient, tenancy-scoped to its
`customer_id`; `is_active` archive flag; encrypted `initial_medical_notes`) and `NurseBankAccounts`
(encrypted `iban` + `UNIQUE(iban_hash)` deterministic-hash duplicate guard + filtered
`UNIQUE(nurse_id) WHERE is_primary=1`). Features live under `Baya.Application/Features/Identity/{Commands|Queries}/`;
one `IEntityTypeConfiguration<T>` each in `Persistence/Configuration/IdentityConfig/`; per-domain
repositories in `Persistence/Repositories/` exposed on `IUnitOfWork` (reads project to DTOs, incl. the
masked IBAN). The **`IBankAccountOwnershipVerifier`** seam (Application `Contracts/Common`; mock
`MockBankAccountOwnershipVerifier` in CrossCutting, registered in `AddCrossCuttingSeams`) runs the mocked
استعلام شبا IBAN-owner ↔ national-id inquiry that sets `matched_national_id` (the b13 first-payout gate).
Encrypted-PII value converters for the new columns are wired in `ApplicationDbContext.OnModelCreating`
alongside the b2 `User` ones. **FluentValidation activation:** `AddApplicationServices` now registers
every `AbstractValidator<T>` in the Application assembly as `IValidator<T>` so the pre-existing
`ValidateCommandBehavior` (and the `ModelStateValidationAttribute` controller filter) actually run —
route-supplied ids (e.g. `patients/update/{id}`) must therefore **not** be validated in the body command.
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or **Keeping the Project map current.** When a change touches the architecture — adds, removes, or
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Identity.Commands.UpsertCustomerProfile;
using Baya.Application.Features.Identity.Queries.GetMyCustomerProfile;
using Baya.Application.Models.Identity;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize]
[Display(Description = "The signed-in customer's payer profile")]
public sealed class CustomerProfilesController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<CustomerProfileDto>]
public async Task<IActionResult> Upsert(UpsertCustomerProfileCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<CustomerProfileDto>]
public async Task<IActionResult> Me(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetMyCustomerProfileQuery(), cancellationToken));
}
@@ -0,0 +1,48 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Identity.Commands.AddNurseBankAccount;
using Baya.Application.Features.Identity.Commands.SetPrimaryBankAccount;
using Baya.Application.Features.Identity.Commands.TriggerBankAccountOwnershipInquiry;
using Baya.Application.Features.Identity.Queries.ListNurseBankAccounts;
using Baya.Application.Models.Identity;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize]
[Display(Description = "The signed-in nurse's payout bank accounts")]
public sealed class NurseBankAccountsController(ISender sender) : BaseController
{
// Rate-limited: adding an account triggers the استعلام شبا vendor inquiry.
[HttpPost("[action]")]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[ProducesOkApiResponseType<NurseBankAccountDto>]
public async Task<IActionResult> Add(AddNurseBankAccountCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> SetPrimary(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new SetPrimaryBankAccountCommand(id), cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<IReadOnlyList<NurseBankAccountDto>>]
public async Task<IActionResult> List(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new ListNurseBankAccountsQuery(), cancellationToken));
// Rate-limited: re-runs the استعلام شبا vendor inquiry.
[HttpPost("[action]/{id}")]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[ProducesOkApiResponseType<NurseBankAccountDto>]
public async Task<IActionResult> VerifyOwnership(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new TriggerBankAccountOwnershipInquiryCommand(id), cancellationToken));
}
@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings;
using Baya.Application.Features.Identity.Commands.UpsertNurseProfile;
using Baya.Application.Features.Identity.Queries.GetMyNurseProfile;
using Baya.Application.Models.Identity;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize]
[Display(Description = "The signed-in nurse's seller profile")]
public sealed class NurseProfilesController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<NurseProfileDto>]
public async Task<IActionResult> Upsert(UpsertNurseProfileCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType]
public async Task<IActionResult> SetAcceptingBookings(SetNurseAcceptingBookingsCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<NurseProfileDto>]
public async Task<IActionResult> Me(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetMyNurseProfileQuery(), cancellationToken));
}
@@ -0,0 +1,49 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Identity.Commands.ArchivePatient;
using Baya.Application.Features.Identity.Commands.CreatePatient;
using Baya.Application.Features.Identity.Commands.UpdatePatient;
using Baya.Application.Features.Identity.Queries.GetPatient;
using Baya.Application.Features.Identity.Queries.ListPatients;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize]
[Display(Description = "The signed-in customer's patients (care recipients)")]
public sealed class PatientsController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<PatientDto>]
public async Task<IActionResult> Create(CreatePatientCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<PagedResult<PatientDto>>]
public async Task<IActionResult> List([FromQuery] ListPatientsQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
[HttpGet("[action]/{id}")]
[ProducesOkApiResponseType<PatientDto>]
public async Task<IActionResult> Get(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetPatientQuery(id), cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<PatientDto>]
public async Task<IActionResult> Update(long id, UpdatePatientCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> Archive(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new ArchivePatientCommand(id), cancellationToken));
}
@@ -0,0 +1,12 @@
namespace Baya.Application.Common;
/// <summary>Wire-masking for sensitive-but-displayable identifiers.</summary>
public static class Mask
{
private const string MaskPrefix = "••••";
/// <summary>Masks an IBAN to its last four characters (e.g. <c>••••3456</c>) so lists never carry the
/// full value. Returns the input unchanged when it is null/empty or already ≤4 chars.</summary>
public static string IbanTail(string iban)
=> string.IsNullOrEmpty(iban) || iban.Length <= 4 ? iban : MaskPrefix + iban[^4..];
}
@@ -0,0 +1,23 @@
#nullable enable
namespace Baya.Application.Contracts.Common;
/// <summary>
/// Seam for the استعلام شبا IBAN-owner ↔ national-id inquiry — the automated check that the bank account
/// an IBAN belongs to is registered to the same national id as the nurse. This replaces the forgeable
/// "an admin eyeballs the IBAN" step and is the money-mule-prevention gate for the first payout (b13).
/// The mock returns a deterministic fake match; the real implementation calls a Finnotech/banking-bridge
/// vendor. No money moves through this seam.
/// </summary>
public interface IBankAccountOwnershipVerifier
{
/// <summary>Runs the ownership inquiry for the given IBAN against the nurse's national id.</summary>
Task<OwnershipInquiryResult> VerifyOwnershipAsync(string iban, string? nurseNationalId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Outcome of an <see cref="IBankAccountOwnershipVerifier"/> inquiry.
/// </summary>
/// <param name="MatchedNationalId">Whether the IBAN owner's national id matches the nurse's.</param>
/// <param name="AccountHolderFromBank">The account-holder name the bank returned (snapshot).</param>
/// <param name="VendorRef">The vendor transaction id, kept for audit.</param>
public readonly record struct OwnershipInquiryResult(bool MatchedNationalId, string AccountHolderFromBank, string VendorRef);
@@ -0,0 +1,21 @@
#nullable enable
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
namespace Baya.Application.Contracts.Persistence;
public interface ICustomerProfileRepository
{
/// <summary>Tracked lookup of the customer's profile by owning user id (for upsert).</summary>
Task<CustomerProfile?> GetByUserIdAsync(int userId, CancellationToken cancellationToken);
Task AddAsync(CustomerProfile profile, CancellationToken cancellationToken);
/// <summary>No-tracking projection of the signed-in customer's profile (emergency contact decrypted,
/// full, for the owner).</summary>
Task<CustomerProfileDto?> GetMineAsync(int userId, CancellationToken cancellationToken);
/// <summary>The customer's <c>customer_profiles.id</c> from their user id — the tenancy anchor for
/// patient operations. NULL when the user has no customer profile yet.</summary>
Task<long?> GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken);
}
@@ -0,0 +1,31 @@
#nullable enable
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
namespace Baya.Application.Contracts.Persistence;
public interface INurseBankAccountRepository
{
Task AddAsync(NurseBankAccount account, CancellationToken cancellationToken);
/// <summary>Tracked, tenancy-scoped lookup — returns the account only if it belongs to
/// <paramref name="nurseId"/>, else null.</summary>
Task<NurseBankAccount?> GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken);
/// <summary>Atomically makes <paramref name="accountId"/> the nurse's primary account and clears any
/// prior primary, in a single transaction (clear-then-set order) so the filtered
/// <c>UNIQUE(nurse_id) WHERE is_primary = 1</c> index never trips. The account must already be owned
/// by the nurse — the caller verifies tenancy first.</summary>
Task SetPrimaryAsync(long nurseId, long accountId, CancellationToken cancellationToken);
/// <summary>Whether any account already carries this deterministic IBAN hash — the clean duplicate
/// guard (the <c>UNIQUE(iban_hash)</c> index is the authoritative backstop).</summary>
Task<bool> IbanHashExistsAsync(string ibanHash, CancellationToken cancellationToken);
/// <summary>Whether the nurse already has at least one account (decides whether a new one defaults to
/// primary).</summary>
Task<bool> HasAnyAsync(long nurseId, CancellationToken cancellationToken);
/// <summary>No-tracking projection of the nurse's accounts with the IBAN masked (last 4 only).</summary>
Task<IReadOnlyList<NurseBankAccountDto>> ListAsync(long nurseId, CancellationToken cancellationToken);
}
@@ -0,0 +1,25 @@
#nullable enable
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
namespace Baya.Application.Contracts.Persistence;
public interface INurseProfileRepository
{
/// <summary>Tracked lookup of the nurse's profile by owning user id (for upsert/toggle).</summary>
Task<NurseProfile?> GetByUserIdAsync(int userId, CancellationToken cancellationToken);
Task AddAsync(NurseProfile profile, CancellationToken cancellationToken);
/// <summary>No-tracking projection of the signed-in nurse's profile, incl. read-only verified flag
/// and aggregates.</summary>
Task<NurseProfileDto?> GetMineAsync(int userId, CancellationToken cancellationToken);
/// <summary>The nurse's <c>nurse_profiles.id</c> from their user id — the tenancy anchor for
/// bank-account operations. NULL when the user has no nurse profile yet.</summary>
Task<long?> GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken);
/// <summary>The nurse's <c>nurse_profiles.id</c> + decrypted national id — what the bank-account
/// ownership inquiry needs. NULL when the user has no nurse profile yet.</summary>
Task<NurseIdentityContext?> GetIdentityContextByUserIdAsync(int userId, CancellationToken cancellationToken);
}
@@ -0,0 +1,22 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
namespace Baya.Application.Contracts.Persistence;
public interface IPatientRepository
{
Task AddAsync(Patient patient, CancellationToken cancellationToken);
/// <summary>Tracked, tenancy-scoped lookup — returns the patient only if it belongs to
/// <paramref name="customerId"/>, else null (so callers surface a not-found, never a cross-tenant
/// mutation).</summary>
Task<Patient?> GetOwnedAsync(long id, long customerId, CancellationToken cancellationToken);
/// <summary>Paginated, no-tracking projection of the customer's own patients.</summary>
Task<PagedResult<PatientDto>> ListAsync(long customerId, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>No-tracking, tenancy-scoped projection of a single owned patient; null if not owned.</summary>
Task<PatientDto?> GetOwnedProjectedAsync(long id, long customerId, CancellationToken cancellationToken);
}
@@ -5,6 +5,10 @@ public interface IUnitOfWork
public IUserRefreshTokenRepository UserRefreshTokenRepository { get; } public IUserRefreshTokenRepository UserRefreshTokenRepository { get; }
public IUserSessionRepository UserSessionRepository { get; } public IUserSessionRepository UserSessionRepository { get; }
public IUserAccountRepository UserAccountRepository { get; } public IUserAccountRepository UserAccountRepository { get; }
public INurseProfileRepository NurseProfileRepository { get; }
public ICustomerProfileRepository CustomerProfileRepository { get; }
public IPatientRepository PatientRepository { get; }
public INurseBankAccountRepository NurseBankAccountRepository { get; }
Task CommitAsync(); Task CommitAsync();
ValueTask RollBackAsync(); ValueTask RollBackAsync();
} }
@@ -0,0 +1,67 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.AddNurseBankAccount;
internal sealed class AddNurseBankAccountCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IFieldEncryptor fieldEncryptor,
IBankAccountOwnershipVerifier ownershipVerifier)
: IRequestHandler<AddNurseBankAccountCommand, OperationResult<NurseBankAccountDto>>
{
public async ValueTask<OperationResult<NurseBankAccountDto>> Handle(AddNurseBankAccountCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<NurseBankAccountDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<NurseBankAccountDto>.ForbiddenResult("Only a nurse can add a payout account.");
var iban = Sheba.Normalize(request.Iban);
if (iban is null)
return OperationResult<NurseBankAccountDto>.FailureResult(nameof(request.Iban), "A valid Iranian IBAN (شبا) is required.");
var context = await unitOfWork.NurseProfileRepository.GetIdentityContextByUserIdAsync(userId, cancellationToken);
if (context is null)
return OperationResult<NurseBankAccountDto>.FailureResult("No nurse profile exists yet. Create your profile first.");
// Deterministic hash is the duplicate guard — the UNIQUE(iban_hash) index is the DB backstop.
var ibanHash = fieldEncryptor.Hash(iban);
if (await unitOfWork.NurseBankAccountRepository.IbanHashExistsAsync(ibanHash, cancellationToken))
return OperationResult<NurseBankAccountDto>.FailureResult(nameof(request.Iban), "This IBAN is already registered.");
var isFirst = !await unitOfWork.NurseBankAccountRepository.HasAnyAsync(context.NurseProfileId, cancellationToken);
var account = new NurseBankAccount
{
NurseId = context.NurseProfileId,
BankName = request.BankName,
AccountHolderName = request.AccountHolderName,
Iban = iban,
IbanHash = ibanHash,
IsPrimary = isFirst
};
var inquiry = await ownershipVerifier.VerifyOwnershipAsync(iban, context.NationalId, cancellationToken);
account.ApplyOwnershipInquiry(inquiry.MatchedNationalId, inquiry.AccountHolderFromBank, inquiry.VendorRef);
await unitOfWork.NurseBankAccountRepository.AddAsync(account, cancellationToken);
await unitOfWork.CommitAsync();
return OperationResult<NurseBankAccountDto>.SuccessResult(new NurseBankAccountDto(
account.Id,
account.BankName,
Mask.IbanTail(iban),
account.IsPrimary,
account.IsVerified,
account.MatchedNationalId));
}
}
@@ -0,0 +1,16 @@
using FluentValidation;
namespace Baya.Application.Features.Identity.Commands.AddNurseBankAccount;
public sealed class AddNurseBankAccountCommandValidator : AbstractValidator<AddNurseBankAccountCommand>
{
public AddNurseBankAccountCommandValidator()
{
RuleFor(x => x.BankName).NotEmpty().MaximumLength(100);
RuleFor(x => x.AccountHolderName).NotEmpty().MaximumLength(200);
RuleFor(x => x.Iban)
.NotEmpty()
.Must(Sheba.IsValid)
.WithMessage("A valid Iranian IBAN (شبا) is required: IR followed by 24 digits.");
}
}
@@ -0,0 +1,15 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.AddNurseBankAccount;
/// <summary>
/// Adds a payout account for the signed-in nurse. The IBAN and account-holder name are encrypted at rest;
/// a deterministic <c>iban_hash</c> guards against duplicates and the استعلام شبا ownership inquiry runs
/// immediately. If the nurse has no other account this one becomes primary.
/// </summary>
public record AddNurseBankAccountCommand(
string BankName,
string AccountHolderName,
string Iban) : IRequest<OperationResult<NurseBankAccountDto>>;
@@ -0,0 +1,34 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.ArchivePatient;
internal sealed class ArchivePatientCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<ArchivePatientCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(ArchivePatientCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
return OperationResult<bool>.ForbiddenResult("Only a customer can manage patients.");
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId is not { } cid)
return OperationResult<bool>.NotFoundResult("Patient not found.");
var patient = await unitOfWork.PatientRepository.GetOwnedAsync(request.Id, cid, cancellationToken);
if (patient is null)
return OperationResult<bool>.NotFoundResult("Patient not found.");
patient.IsActive = false;
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.ArchivePatient;
/// <summary>Soft-archives a patient (<c>is_active = false</c>) the signed-in customer owns — not a hard
/// delete, so the longitudinal care record (b14) is preserved.</summary>
public record ArchivePatientCommand(long Id) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,63 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.CreatePatient;
internal sealed class CreatePatientCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<CreatePatientCommand, OperationResult<PatientDto>>
{
public async ValueTask<OperationResult<PatientDto>> Handle(CreatePatientCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<PatientDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
return OperationResult<PatientDto>.ForbiddenResult("Only a customer can register a patient.");
var patient = new Patient
{
DisplayName = request.DisplayName,
FirstName = request.FirstName,
LastName = request.LastName,
BirthDate = request.BirthDate,
Gender = request.Gender,
BloodType = request.BloodType,
InitialMedicalNotes = request.InitialMedicalNotes,
IsActive = true
};
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId is { } existingCustomerId)
{
patient.CustomerId = existingCustomerId;
}
else
{
// First patient before any customer-profile save — provision the thin payer row so the
// customer/patient split works without a separate profile step. The FK is fixed up on commit.
var profile = new CustomerProfile { UserId = userId };
await unitOfWork.CustomerProfileRepository.AddAsync(profile, cancellationToken);
patient.Customer = profile;
}
await unitOfWork.PatientRepository.AddAsync(patient, cancellationToken);
await unitOfWork.CommitAsync();
return OperationResult<PatientDto>.SuccessResult(new PatientDto(
patient.Id,
patient.DisplayName,
patient.FirstName,
patient.LastName,
patient.BirthDate,
patient.Gender,
patient.BloodType,
patient.InitialMedicalNotes,
patient.IsActive));
}
}
@@ -0,0 +1,21 @@
using FluentValidation;
namespace Baya.Application.Features.Identity.Commands.CreatePatient;
public sealed class CreatePatientCommandValidator : AbstractValidator<CreatePatientCommand>
{
public CreatePatientCommandValidator()
{
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(200);
RuleFor(x => x.FirstName).MaximumLength(100);
RuleFor(x => x.LastName).MaximumLength(100);
RuleFor(x => x.BloodType).MaximumLength(10);
RuleFor(x => x.Gender)
.Must(PatientRules.IsValidGender)
.WithMessage("Gender is required and must be 'male' or 'female'.");
RuleFor(x => x.BirthDate)
.NotEqual(default(DateOnly))
.Must(PatientRules.IsNotFuture)
.WithMessage("Birth date cannot be in the future.");
}
}
@@ -0,0 +1,19 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.CreatePatient;
/// <summary>
/// Registers a care recipient under the signed-in customer. The owning customer is derived from the
/// caller — never from the request body. <c>Gender</c> is required (same-gender matching signal) and
/// <c>InitialMedicalNotes</c> is encrypted at rest.
/// </summary>
public record CreatePatientCommand(
string DisplayName,
string FirstName,
string LastName,
DateOnly BirthDate,
string Gender,
string BloodType,
string InitialMedicalNotes) : IRequest<OperationResult<PatientDto>>;
@@ -0,0 +1,30 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings;
internal sealed class SetNurseAcceptingBookingsCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<SetNurseAcceptingBookingsCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(SetNurseAcceptingBookingsCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<bool>.ForbiddenResult("Only a nurse can manage a nurse profile.");
var profile = await unitOfWork.NurseProfileRepository.GetByUserIdAsync(userId, cancellationToken);
if (profile is null)
return OperationResult<bool>.NotFoundResult("No nurse profile exists yet. Create your profile first.");
profile.SetAcceptingBookings(request.Accepting);
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,7 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings;
/// <summary>Pauses or resumes the signed-in nurse's bookability without touching verified status.</summary>
public record SetNurseAcceptingBookingsCommand(bool Accepting) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,36 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.SetPrimaryBankAccount;
internal sealed class SetPrimaryBankAccountCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<SetPrimaryBankAccountCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(SetPrimaryBankAccountCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<bool>.ForbiddenResult("Only a nurse can manage payout accounts.");
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } nid)
return OperationResult<bool>.NotFoundResult("Bank account not found.");
// Verify tenancy before touching any row — a non-owned/nonexistent id must never clear the
// existing primary.
var account = await unitOfWork.NurseBankAccountRepository.GetOwnedAsync(request.Id, nid, cancellationToken);
if (account is null)
return OperationResult<bool>.NotFoundResult("Bank account not found.");
if (!account.IsPrimary)
await unitOfWork.NurseBankAccountRepository.SetPrimaryAsync(nid, request.Id, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,7 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.SetPrimaryBankAccount;
/// <summary>Makes one of the signed-in nurse's accounts primary, clearing the prior primary atomically.</summary>
public record SetPrimaryBankAccountCommand(long Id) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,48 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.TriggerBankAccountOwnershipInquiry;
internal sealed class TriggerBankAccountOwnershipInquiryCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IBankAccountOwnershipVerifier ownershipVerifier)
: IRequestHandler<TriggerBankAccountOwnershipInquiryCommand, OperationResult<NurseBankAccountDto>>
{
public async ValueTask<OperationResult<NurseBankAccountDto>> Handle(TriggerBankAccountOwnershipInquiryCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<NurseBankAccountDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<NurseBankAccountDto>.ForbiddenResult("Only a nurse can run an ownership inquiry.");
var context = await unitOfWork.NurseProfileRepository.GetIdentityContextByUserIdAsync(userId, cancellationToken);
if (context is null)
return OperationResult<NurseBankAccountDto>.NotFoundResult("Bank account not found.");
var account = await unitOfWork.NurseBankAccountRepository.GetOwnedAsync(request.Id, context.NurseProfileId, cancellationToken);
if (account is null)
return OperationResult<NurseBankAccountDto>.NotFoundResult("Bank account not found.");
// The tracked entity decrypts the IBAN on materialization, so we can re-run the inquiry on it.
var inquiry = await ownershipVerifier.VerifyOwnershipAsync(account.Iban, context.NationalId, cancellationToken);
account.ApplyOwnershipInquiry(inquiry.MatchedNationalId, inquiry.AccountHolderFromBank, inquiry.VendorRef);
await unitOfWork.CommitAsync();
return OperationResult<NurseBankAccountDto>.SuccessResult(new NurseBankAccountDto(
account.Id,
account.BankName,
Mask.IbanTail(account.Iban),
account.IsPrimary,
account.IsVerified,
account.MatchedNationalId));
}
}
@@ -0,0 +1,12 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.TriggerBankAccountOwnershipInquiry;
/// <summary>
/// Re-runs the استعلام شبا ownership inquiry for an existing account (e.g. after a NULL/failed first
/// attempt) and updates <c>matched_national_id</c>/<c>account_holder_from_bank</c>/
/// <c>ownership_vendor_ref</c>. Idempotent — the same input yields the same vendor ref from the mock.
/// </summary>
public record TriggerBankAccountOwnershipInquiryCommand(long Id) : IRequest<OperationResult<NurseBankAccountDto>>;
@@ -0,0 +1,51 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.UpdatePatient;
internal sealed class UpdatePatientCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<UpdatePatientCommand, OperationResult<PatientDto>>
{
public async ValueTask<OperationResult<PatientDto>> Handle(UpdatePatientCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<PatientDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
return OperationResult<PatientDto>.ForbiddenResult("Only a customer can manage patients.");
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId is not { } cid)
return OperationResult<PatientDto>.NotFoundResult("Patient not found.");
var patient = await unitOfWork.PatientRepository.GetOwnedAsync(request.Id, cid, cancellationToken);
if (patient is null)
return OperationResult<PatientDto>.NotFoundResult("Patient not found.");
patient.DisplayName = request.DisplayName;
patient.FirstName = request.FirstName;
patient.LastName = request.LastName;
patient.BirthDate = request.BirthDate;
patient.Gender = request.Gender;
patient.BloodType = request.BloodType;
patient.InitialMedicalNotes = request.InitialMedicalNotes;
await unitOfWork.CommitAsync();
return OperationResult<PatientDto>.SuccessResult(new PatientDto(
patient.Id,
patient.DisplayName,
patient.FirstName,
patient.LastName,
patient.BirthDate,
patient.Gender,
patient.BloodType,
patient.InitialMedicalNotes,
patient.IsActive));
}
}
@@ -0,0 +1,22 @@
using FluentValidation;
namespace Baya.Application.Features.Identity.Commands.UpdatePatient;
public sealed class UpdatePatientCommandValidator : AbstractValidator<UpdatePatientCommand>
{
public UpdatePatientCommandValidator()
{
// Id is supplied by the route, not the request body, so it is not validated here.
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(200);
RuleFor(x => x.FirstName).MaximumLength(100);
RuleFor(x => x.LastName).MaximumLength(100);
RuleFor(x => x.BloodType).MaximumLength(10);
RuleFor(x => x.Gender)
.Must(PatientRules.IsValidGender)
.WithMessage("Gender is required and must be 'male' or 'female'.");
RuleFor(x => x.BirthDate)
.NotEqual(default(DateOnly))
.Must(PatientRules.IsNotFuture)
.WithMessage("Birth date cannot be in the future.");
}
}
@@ -0,0 +1,16 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.UpdatePatient;
/// <summary>Updates a patient the signed-in customer owns; tenancy-checked, re-encrypts changed PII.</summary>
public record UpdatePatientCommand(
long Id,
string DisplayName,
string FirstName,
string LastName,
DateOnly BirthDate,
string Gender,
string BloodType,
string InitialMedicalNotes) : IRequest<OperationResult<PatientDto>>;
@@ -0,0 +1,47 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.UpsertCustomerProfile;
internal sealed class UpsertCustomerProfileCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<UpsertCustomerProfileCommand, OperationResult<CustomerProfileDto>>
{
public async ValueTask<OperationResult<CustomerProfileDto>> Handle(UpsertCustomerProfileCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<CustomerProfileDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
return OperationResult<CustomerProfileDto>.ForbiddenResult("Only a customer can manage a customer profile.");
var phone = IranianPhone.Normalize(request.DefaultEmergencyContactPhone) ?? request.DefaultEmergencyContactPhone;
var profile = await unitOfWork.CustomerProfileRepository.GetByUserIdAsync(userId, cancellationToken);
if (profile is null)
{
profile = new CustomerProfile
{
UserId = userId,
DefaultEmergencyContactName = request.DefaultEmergencyContactName,
DefaultEmergencyContactPhone = phone
};
await unitOfWork.CustomerProfileRepository.AddAsync(profile, cancellationToken);
}
else
{
profile.DefaultEmergencyContactName = request.DefaultEmergencyContactName;
profile.DefaultEmergencyContactPhone = phone;
}
await unitOfWork.CommitAsync();
var dto = await unitOfWork.CustomerProfileRepository.GetMineAsync(userId, cancellationToken);
return OperationResult<CustomerProfileDto>.SuccessResult(dto!);
}
}
@@ -0,0 +1,15 @@
using FluentValidation;
namespace Baya.Application.Features.Identity.Commands.UpsertCustomerProfile;
public sealed class UpsertCustomerProfileCommandValidator : AbstractValidator<UpsertCustomerProfileCommand>
{
public UpsertCustomerProfileCommandValidator()
{
RuleFor(x => x.DefaultEmergencyContactName).NotEmpty().MaximumLength(200);
RuleFor(x => x.DefaultEmergencyContactPhone)
.NotEmpty()
.Must(IranianPhone.IsValid)
.WithMessage("A valid Iranian mobile number is required (09xxxxxxxxx).");
}
}
@@ -0,0 +1,13 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.UpsertCustomerProfile;
/// <summary>
/// Creates (first call) or updates the signed-in customer's payer profile and its default emergency
/// contact (encrypted at rest). Idempotent on the owning user.
/// </summary>
public record UpsertCustomerProfileCommand(
string DefaultEmergencyContactName,
string DefaultEmergencyContactPhone) : IRequest<OperationResult<CustomerProfileDto>>;
@@ -0,0 +1,51 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.UpsertNurseProfile;
internal sealed class UpsertNurseProfileCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<UpsertNurseProfileCommand, OperationResult<NurseProfileDto>>
{
public async ValueTask<OperationResult<NurseProfileDto>> Handle(UpsertNurseProfileCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<NurseProfileDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<NurseProfileDto>.ForbiddenResult("Only a nurse can manage a nurse profile.");
var profile = await unitOfWork.NurseProfileRepository.GetByUserIdAsync(userId, cancellationToken);
if (profile is null)
{
// Created unverified and not accepting bookings — verification (b6) is the only path to
// is_verified; the nurse opts into bookability separately.
profile = new NurseProfile { UserId = userId };
Apply(profile, request);
await unitOfWork.NurseProfileRepository.AddAsync(profile, cancellationToken);
}
else
{
Apply(profile, request);
}
await unitOfWork.CommitAsync();
var dto = await unitOfWork.NurseProfileRepository.GetMineAsync(userId, cancellationToken);
return OperationResult<NurseProfileDto>.SuccessResult(dto!);
}
private static void Apply(NurseProfile profile, UpsertNurseProfileCommand request)
{
profile.Bio = request.Bio;
profile.YearsOfExperience = request.YearsOfExperience;
profile.EducationLevel = request.EducationLevel;
profile.EducationField = request.EducationField;
profile.SpecializationsJson = request.SpecializationsJson;
}
}
@@ -0,0 +1,14 @@
using FluentValidation;
namespace Baya.Application.Features.Identity.Commands.UpsertNurseProfile;
public sealed class UpsertNurseProfileCommandValidator : AbstractValidator<UpsertNurseProfileCommand>
{
public UpsertNurseProfileCommandValidator()
{
RuleFor(x => x.Bio).MaximumLength(2000);
RuleFor(x => x.YearsOfExperience).InclusiveBetween(0, 80);
RuleFor(x => x.EducationLevel).MaximumLength(100);
RuleFor(x => x.EducationField).MaximumLength(150);
}
}
@@ -0,0 +1,16 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.UpsertNurseProfile;
/// <summary>
/// Creates (first call) or updates the signed-in nurse's seller profile. Never accepts the guarded
/// <c>is_verified</c> flag or the read-only aggregates. Idempotent on the owning user.
/// </summary>
public record UpsertNurseProfileCommand(
string Bio,
int YearsOfExperience,
string EducationLevel,
string EducationField,
string SpecializationsJson) : IRequest<OperationResult<NurseProfileDto>>;
@@ -0,0 +1,9 @@
namespace Baya.Application.Features.Identity;
/// <summary>Shared patient input rules used by the create/update validators.</summary>
internal static class PatientRules
{
public static bool IsValidGender(string gender) => gender is "male" or "female";
public static bool IsNotFuture(DateOnly birthDate) => birthDate <= DateOnly.FromDateTime(DateTime.UtcNow.Date);
}
@@ -0,0 +1,23 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;
namespace Baya.Application.Features.Identity.Queries.GetMyCustomerProfile;
internal sealed class GetMyCustomerProfileQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<GetMyCustomerProfileQuery, OperationResult<CustomerProfileDto>>
{
public async ValueTask<OperationResult<CustomerProfileDto>> Handle(GetMyCustomerProfileQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<CustomerProfileDto>.UnauthorizedResult("Not authenticated.");
var dto = await unitOfWork.CustomerProfileRepository.GetMineAsync(userId, cancellationToken);
return dto is null
? OperationResult<CustomerProfileDto>.NotFoundResult("No customer profile exists yet.")
: OperationResult<CustomerProfileDto>.SuccessResult(dto);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;
namespace Baya.Application.Features.Identity.Queries.GetMyCustomerProfile;
/// <summary>Projects the signed-in customer's profile (emergency contact returned in full to the owner).</summary>
public record GetMyCustomerProfileQuery : IRequest<OperationResult<CustomerProfileDto>>;
@@ -0,0 +1,23 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;
namespace Baya.Application.Features.Identity.Queries.GetMyNurseProfile;
internal sealed class GetMyNurseProfileQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<GetMyNurseProfileQuery, OperationResult<NurseProfileDto>>
{
public async ValueTask<OperationResult<NurseProfileDto>> Handle(GetMyNurseProfileQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<NurseProfileDto>.UnauthorizedResult("Not authenticated.");
var dto = await unitOfWork.NurseProfileRepository.GetMineAsync(userId, cancellationToken);
return dto is null
? OperationResult<NurseProfileDto>.NotFoundResult("No nurse profile exists yet.")
: OperationResult<NurseProfileDto>.SuccessResult(dto);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;
namespace Baya.Application.Features.Identity.Queries.GetMyNurseProfile;
/// <summary>Projects the signed-in nurse's profile, incl. read-only verified flag and aggregates.</summary>
public record GetMyNurseProfileQuery : IRequest<OperationResult<NurseProfileDto>>;
@@ -0,0 +1,28 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;
namespace Baya.Application.Features.Identity.Queries.GetPatient;
internal sealed class GetPatientQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<GetPatientQuery, OperationResult<PatientDto>>
{
public async ValueTask<OperationResult<PatientDto>> Handle(GetPatientQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<PatientDto>.UnauthorizedResult("Not authenticated.");
// A patient outside the caller's tenancy is indistinguishable from a non-existent one.
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId is not { } cid)
return OperationResult<PatientDto>.NotFoundResult("Patient not found.");
var dto = await unitOfWork.PatientRepository.GetOwnedProjectedAsync(request.Id, cid, cancellationToken);
return dto is null
? OperationResult<PatientDto>.NotFoundResult("Patient not found.")
: OperationResult<PatientDto>.SuccessResult(dto);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;
namespace Baya.Application.Features.Identity.Queries.GetPatient;
/// <summary>Returns one patient only if it belongs to the signed-in customer, else not-found.</summary>
public record GetPatientQuery(long Id) : IRequest<OperationResult<PatientDto>>;
@@ -0,0 +1,29 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Identity.Queries.ListNurseBankAccounts;
internal sealed class ListNurseBankAccountsQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<ListNurseBankAccountsQuery, OperationResult<IReadOnlyList<NurseBankAccountDto>>>
{
public async ValueTask<OperationResult<IReadOnlyList<NurseBankAccountDto>>> Handle(ListNurseBankAccountsQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<IReadOnlyList<NurseBankAccountDto>>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<IReadOnlyList<NurseBankAccountDto>>.ForbiddenResult("Only a nurse can view payout accounts.");
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } nid)
return OperationResult<IReadOnlyList<NurseBankAccountDto>>.SuccessResult([]);
var accounts = await unitOfWork.NurseBankAccountRepository.ListAsync(nid, cancellationToken);
return OperationResult<IReadOnlyList<NurseBankAccountDto>>.SuccessResult(accounts);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;
namespace Baya.Application.Features.Identity.Queries.ListNurseBankAccounts;
/// <summary>Lists the signed-in nurse's payout accounts with the IBAN masked (last 4 only).</summary>
public record ListNurseBankAccountsQuery : IRequest<OperationResult<IReadOnlyList<NurseBankAccountDto>>>;
@@ -0,0 +1,28 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;
namespace Baya.Application.Features.Identity.Queries.ListPatients;
internal sealed class ListPatientsQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<ListPatientsQuery, OperationResult<PagedResult<PatientDto>>>
{
public async ValueTask<OperationResult<PagedResult<PatientDto>>> Handle(ListPatientsQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<PagedResult<PatientDto>>.UnauthorizedResult("Not authenticated.");
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId is not { } cid)
return OperationResult<PagedResult<PatientDto>>.SuccessResult(new PagedResult<PatientDto>([], 0, page, pageSize));
var result = await unitOfWork.PatientRepository.ListAsync(cid, page, pageSize, cancellationToken);
return OperationResult<PagedResult<PatientDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;
namespace Baya.Application.Features.Identity.Queries.ListPatients;
/// <summary>Lists the signed-in customer's own patients only (tenancy-scoped, paginated).</summary>
public record ListPatientsQuery(int Page = 1, int PageSize = 50)
: IRequest<OperationResult<PagedResult<PatientDto>>>;
@@ -0,0 +1,27 @@
#nullable enable
using System.Text.RegularExpressions;
using Baya.SharedKernel.Extensions;
namespace Baya.Application.Features.Identity;
/// <summary>
/// Normalizes and validates an Iranian IBAN (شبا): the canonical <c>IR</c> + 24 digits, uppercase, no
/// spaces (Persian digits translated). The canonical form is what gets stored, encrypted, and hashed —
/// so the deterministic <c>iban_hash</c> uniqueness holds regardless of how the nurse typed it.
/// </summary>
internal static partial class Sheba
{
[GeneratedRegex(@"^IR\d{24}$")]
private static partial Regex ShebaPattern();
public static string? Normalize(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return null;
var candidate = raw.Trim().Fa2En().Replace(" ", string.Empty).Replace("-", string.Empty).ToUpperInvariant();
return ShebaPattern().IsMatch(candidate) ? candidate : null;
}
public static bool IsValid(string? raw) => Normalize(raw) is not null;
}
@@ -0,0 +1,10 @@
namespace Baya.Application.Models.Identity;
/// <summary>
/// The signed-in customer's payer profile. The emergency-contact fields are decrypted and returned in
/// full because the endpoint only ever serves the owning customer (self).
/// </summary>
public record CustomerProfileDto(
long Id,
string DefaultEmergencyContactName,
string DefaultEmergencyContactPhone);
@@ -0,0 +1,13 @@
namespace Baya.Application.Models.Identity;
/// <summary>
/// A nurse payout account. The IBAN is returned <b>masked</b> (last 4 only) — the full value is never
/// sent on the wire. <c>MatchedNationalId</c> is NULL until the استعلام شبا ownership inquiry has run.
/// </summary>
public record NurseBankAccountDto(
long Id,
string BankName,
string IbanMasked,
bool IsPrimary,
bool IsVerified,
bool? MatchedNationalId);
@@ -0,0 +1,10 @@
namespace Baya.Application.Models.Identity;
/// <summary>
/// The minimal nurse facts a bank-account command needs: the owning <c>nurse_profiles</c> id and the
/// nurse's national id (decrypted) for the استعلام شبا ownership inquiry. National id is NULL until the
/// b6 KYC pipeline populates it.
/// </summary>
/// <param name="NurseProfileId">The signed-in nurse's <c>nurse_profiles.id</c>.</param>
/// <param name="NationalId">The nurse's national id, decrypted; NULL before KYC.</param>
public record NurseIdentityContext(long NurseProfileId, string NationalId);
@@ -0,0 +1,18 @@
namespace Baya.Application.Models.Identity;
/// <summary>
/// The signed-in nurse's seller profile. <c>IsVerified</c> and the aggregates are read-only — they are
/// never set through a profile command in this phase.
/// </summary>
public record NurseProfileDto(
long Id,
string Bio,
int YearsOfExperience,
string EducationLevel,
string EducationField,
string SpecializationsJson,
bool IsVerified,
bool IsAcceptingBookings,
decimal AverageRating,
int TotalReviews,
int TotalCompletedBookings);
@@ -0,0 +1,16 @@
namespace Baya.Application.Models.Identity;
/// <summary>
/// A care recipient owned by the signed-in customer. <c>InitialMedicalNotes</c> is decrypted and
/// returned only to the owning customer.
/// </summary>
public record PatientDto(
long Id,
string DisplayName,
string FirstName,
string LastName,
DateOnly BirthDate,
string Gender,
string BloodType,
string InitialMedicalNotes,
bool IsActive);
@@ -1,6 +1,5 @@
using System.Reflection; using Baya.Application.Common;
using Baya.Application.Common; using FluentValidation;
using Mapster;
using Mediator; using Mediator;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -22,10 +21,29 @@ public static class ServiceCollectionExtension
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidateCommandBehavior<,>)); services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidateCommandBehavior<,>));
RegisterCommandValidators(services);
return services; return services;
} }
// Registers every FluentValidation AbstractValidator<T> in this assembly as IValidator<T> so the
// ValidateCommandBehavior can resolve and run them. Done by hand (rather than pulling in the
// FluentValidation.DependencyInjectionExtensions package) to keep the dependency surface minimal.
private static void RegisterCommandValidators(IServiceCollection services)
{
var assembly = typeof(ValidateCommandBehavior<,>).Assembly;
foreach (var type in assembly.GetTypes())
{
if (type.IsAbstract || type.IsInterface)
continue;
var validatorInterface = Array.Find(
type.GetInterfaces(),
i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IValidator<>));
if (validatorInterface is not null)
services.AddScoped(validatorInterface, type);
}
}
} }
@@ -0,0 +1,22 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Identity;
/// <summary>
/// The thin payer extension for a customer. Intentionally lightweight — most customer reality lives in
/// their <c>patients</c>, addresses and bookings. Customer national-ID KYC is deferred at launch, so no
/// verification columns exist here.
/// </summary>
public class CustomerProfile : BaseEntity<long>
{
public int UserId { get; set; }
public User.User User { get; set; }
/// <summary>Encrypted at rest.</summary>
public string DefaultEmergencyContactName { get; set; }
/// <summary>Encrypted at rest.</summary>
public string DefaultEmergencyContactPhone { get; set; }
public ICollection<Patient> Patients { get; set; }
}
@@ -0,0 +1,53 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Identity;
/// <summary>
/// A nurse's payout destination (IBAN/Sheba) — the single place real money will one day leave the
/// platform. Hardened: the IBAN is encrypted, a deterministic <see cref="IbanHash"/> carries a UNIQUE
/// constraint so one IBAN can't silently serve two nurses, and an automated استعلام شبا ownership
/// inquiry records whether the IBAN owner matches the nurse's national id
/// (<see cref="MatchedNationalId"/>) — the first-payout gate (b13), not an admin's eyeballs.
/// </summary>
public class NurseBankAccount : BaseEntity<long>
{
public long NurseId { get; set; }
public NurseProfile Nurse { get; set; }
public string BankName { get; set; }
/// <summary>Encrypted at rest.</summary>
public string AccountHolderName { get; set; }
/// <summary>Encrypted at rest. Non-deterministic ciphertext — never equality-queried; lookups go
/// through <see cref="IbanHash"/>.</summary>
public string Iban { get; set; }
/// <summary>Deterministic keyed hash of the normalized IBAN (via <c>IFieldEncryptor.Hash</c>) —
/// carries the UNIQUE index, since the ciphertext itself can't be uniquely indexed.</summary>
public string IbanHash { get; set; }
public bool IsPrimary { get; set; }
/// <summary>Result of the استعلام شبا IBAN-owner ↔ national-id inquiry. NULL until the inquiry runs;
/// the first payout (b13) is gated on <c>true</c>.</summary>
public bool? MatchedNationalId { get; set; }
/// <summary>The account-holder name the bank returned by the ownership inquiry — a snapshot.</summary>
public string AccountHolderFromBank { get; set; }
/// <summary>The ownership-inquiry vendor transaction id, kept for audit.</summary>
public string OwnershipVendorRef { get; set; }
public bool IsVerified { get; set; }
public int? VerifiedByAdminId { get; set; }
public DateTimeOffset? VerifiedAt { get; set; }
/// <summary>Records the outcome of an استعلام شبا ownership inquiry against this account.</summary>
public void ApplyOwnershipInquiry(bool matchedNationalId, string accountHolderFromBank, string vendorRef)
{
MatchedNationalId = matchedNationalId;
AccountHolderFromBank = accountHolderFromBank;
OwnershipVendorRef = vendorRef;
}
}
@@ -0,0 +1,54 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Identity;
/// <summary>
/// A nurse's seller profile plus the denormalized search/quality aggregates. Separated from
/// <c>users</c> so the base identity row stays lean and the nurse-only attributes (and the aggregates
/// search reads on every query) live together. A profile is created unverified and not accepting
/// bookings — a nurse is not bookable until the b6 verification pipeline flips <see cref="IsVerified"/>.
/// </summary>
public class NurseProfile : BaseEntity<long>
{
public int UserId { get; set; }
public User.User User { get; set; }
/// <summary>The licensed center legally sponsoring this nurse at launch (Asanism model). NULL once
/// Balinyaar holds its own permit. Forward dependency on <c>partner_centers</c> (b15) — nullable,
/// no FK target is enforced in this phase.</summary>
public long? PartnerCenterId { get; set; }
public string Bio { get; set; }
public int YearsOfExperience { get; set; }
public string EducationLevel { get; set; }
public string EducationField { get; set; }
public string SpecializationsJson { get; set; }
/// <summary>Write-guarded. Flipped ONLY inside the b6 verification-confirm transaction once every
/// required verification step has passed — never from a profile command in this phase. A nurse is
/// not bookable until this is true.</summary>
public bool IsVerified { get; private set; }
/// <summary>Whether the nurse currently accepts new bookings. A nurse can pause without losing
/// verified status — toggled via <see cref="SetAcceptingBookings"/>.</summary>
public bool IsAcceptingBookings { get; private set; }
/// <summary>Denormalized read-only aggregate. Defaults to 0; recomputed by the reviews/bookings
/// phases (b9/b14) — never accepted from a request in this phase.</summary>
public decimal AverageRating { get; private set; }
public int TotalReviews { get; private set; }
public int TotalCompletedBookings { get; private set; }
public DateTimeOffset? DeletedAt { get; set; }
public ICollection<NurseBankAccount> BankAccounts { get; set; }
/// <summary>The single sanctioned write path for the guarded verified flag — called only by the b6
/// verification-confirm transaction.</summary>
public void MarkVerified() => IsVerified = true;
public void MarkUnverified() => IsVerified = false;
public void SetAcceptingBookings(bool accepting) => IsAcceptingBookings = accepting;
}
@@ -0,0 +1,32 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Identity;
/// <summary>
/// The person receiving care — a first-class entity separate from the payer, because the customer
/// (an adult child, a spouse) is frequently not the patient (an elderly parent, a newborn, a
/// post-surgical adult). One customer registers many patients. Every read/write is tenancy-scoped to the
/// owning <see cref="CustomerId"/>.
/// </summary>
public class Patient : BaseEntity<long>
{
public long CustomerId { get; set; }
public CustomerProfile Customer { get; set; }
public string DisplayName { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateOnly BirthDate { get; set; }
/// <summary>"male" / "female". Required — load-bearing for same-gender caregiver matching.</summary>
public string Gender { get; set; }
public string BloodType { get; set; }
/// <summary>Encrypted at rest.</summary>
public string InitialMedicalNotes { get; set; }
/// <summary>Archive flag — a patient is soft-archived (not hard-deleted) so the longitudinal care
/// record (b14) is preserved.</summary>
public bool IsActive { get; set; }
}
@@ -0,0 +1,38 @@
#nullable enable
using System.Security.Cryptography;
using System.Text;
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Mock <see cref="IBankAccountOwnershipVerifier"/>: a deterministic fake استعلام شبا inquiry — no real
/// bank/KYC call and no money moves. Every IBAN returns a match except the configured
/// <see cref="BankOwnershipOptions.MismatchIban"/>, which returns <c>MatchedNationalId = false</c> so the
/// ownership-mismatch path is testable. The vendor ref is derived from the IBAN, so re-running the same
/// inquiry is idempotent. The real implementation (Finnotech/banking-bridge) swaps in via a registration
/// change — callers are unchanged.
/// </summary>
public sealed class MockBankAccountOwnershipVerifier(IOptions<SeamOptions> options) : IBankAccountOwnershipVerifier
{
private readonly BankOwnershipOptions _options = options.Value.BankOwnership;
// nurseNationalId is part of the real vendor contract (owner ↔ national-id match); the mock decides
// the outcome from the IBAN alone so both paths are deterministically testable.
public Task<OwnershipInquiryResult> VerifyOwnershipAsync(string iban, string? nurseNationalId, CancellationToken cancellationToken = default)
{
var normalized = Normalize(iban);
var matched = !string.Equals(normalized, Normalize(_options.MismatchIban), StringComparison.OrdinalIgnoreCase);
var holder = matched ? _options.MatchedHolderName : _options.MismatchHolderName;
var vendorRef = $"MOCK-SHEBA-{Token(normalized)}";
return Task.FromResult(new OwnershipInquiryResult(matched, holder, vendorRef));
}
private static string Normalize(string iban)
=> string.IsNullOrEmpty(iban) ? string.Empty : iban.Replace(" ", string.Empty).ToUpperInvariant();
private static string Token(string value)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..12];
}
@@ -10,6 +10,24 @@ public sealed class SeamOptions
public FieldEncryptionOptions FieldEncryption { get; set; } = new(); public FieldEncryptionOptions FieldEncryption { get; set; } = new();
public ObjectStorageOptions ObjectStorage { get; set; } = new(); public ObjectStorageOptions ObjectStorage { get; set; } = new();
public BankOwnershipOptions BankOwnership { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>IBankAccountOwnershipVerifier</c> (استعلام شبا). A submitted IBAN equal to
/// <see cref="MismatchIban"/> returns an ownership mismatch so the payout-gating path is testable; every
/// other IBAN returns a match. The real vendor implementation ignores these.
/// </summary>
public sealed class BankOwnershipOptions
{
/// <summary>The designated test IBAN that returns <c>matched_national_id = false</c>.</summary>
public string MismatchIban { get; set; } = "IR000000000000000000000000";
/// <summary>The account-holder name the mock echoes back for a matching inquiry.</summary>
public string MatchedHolderName { get; set; } = "Verified Account Holder";
/// <summary>The account-holder name the mock returns for the mismatch IBAN.</summary>
public string MismatchHolderName { get; set; } = "Unmatched Account Holder";
} }
public sealed class FieldEncryptionOptions public sealed class FieldEncryptionOptions
@@ -28,6 +28,10 @@ public static class ServiceCollectionExtension
// (Kavenegar/Ghasedak/SMS.ir) replaces this registration only. // (Kavenegar/Ghasedak/SMS.ir) replaces this registration only.
services.AddSingleton<ISmsSender, LoggingSmsSender>(); services.AddSingleton<ISmsSender, LoggingSmsSender>();
// استعلام شبا IBAN-owner ↔ national-id inquiry (backend-phase-3). The mock returns a deterministic
// fake match; a real Finnotech/banking-bridge client replaces this registration only.
services.AddSingleton<IBankAccountOwnershipVerifier, MockBankAccountOwnershipVerifier>();
return services; return services;
} }
} }
@@ -1,6 +1,7 @@
using System.Reflection; using System.Reflection;
using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Common;
using Baya.Domain.Common; using Baya.Domain.Common;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User; using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence.ValueConversion; using Baya.Infrastructure.Persistence.ValueConversion;
using Baya.SharedKernel.Extensions; using Baya.SharedKernel.Extensions;
@@ -103,5 +104,22 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
builder.Property(u => u.NormalizedEmail).HasConversion(encrypted); builder.Property(u => u.NormalizedEmail).HasConversion(encrypted);
builder.Property(u => u.NationalId).HasConversion(encrypted); builder.Property(u => u.NationalId).HasConversion(encrypted);
}); });
// b3 PII: emergency contacts, clinical notes, IBAN and the account-holder name are encrypted at
// rest through the same seam. The IBAN's deterministic lookup uses the iban_hash column instead.
modelBuilder.Entity<CustomerProfile>(builder =>
{
builder.Property(c => c.DefaultEmergencyContactName).HasConversion(encrypted);
builder.Property(c => c.DefaultEmergencyContactPhone).HasConversion(encrypted);
});
modelBuilder.Entity<Patient>(builder =>
{
builder.Property(p => p.InitialMedicalNotes).HasConversion(encrypted);
});
modelBuilder.Entity<NurseBankAccount>(builder =>
{
builder.Property(a => a.AccountHolderName).HasConversion(encrypted);
builder.Property(a => a.Iban).HasConversion(encrypted);
});
} }
} }
@@ -0,0 +1,22 @@
using Baya.Domain.Entities.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.IdentityConfig;
internal sealed class CustomerProfileConfig : IEntityTypeConfiguration<CustomerProfile>
{
public void Configure(EntityTypeBuilder<CustomerProfile> builder)
{
builder.ToTable("CustomerProfiles", "usr");
// Emergency-contact columns are encrypted at rest (converter wired in ApplicationDbContext) —
// left as nvarchar(max) since ciphertext is longer than the plaintext it carries.
builder.HasIndex(c => c.UserId).IsUnique();
builder.HasOne(c => c.User)
.WithOne()
.HasForeignKey<CustomerProfile>(c => c.UserId)
.IsRequired();
}
}
@@ -0,0 +1,43 @@
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.IdentityConfig;
internal sealed class NurseBankAccountConfig : IEntityTypeConfiguration<NurseBankAccount>
{
public void Configure(EntityTypeBuilder<NurseBankAccount> builder)
{
builder.ToTable("NurseBankAccounts", "usr");
builder.Property(a => a.BankName).HasMaxLength(100);
builder.Property(a => a.IbanHash).HasMaxLength(64).IsRequired();
builder.Property(a => a.AccountHolderFromBank).HasMaxLength(200);
builder.Property(a => a.OwnershipVendorRef).HasMaxLength(200);
builder.Property(a => a.IsPrimary).HasDefaultValue(false);
builder.Property(a => a.IsVerified).HasDefaultValue(false);
// account_holder_name and iban are encrypted at rest (converters wired in ApplicationDbContext).
// One IBAN can't silently serve two nurses — the authoritative duplicate backstop.
builder.HasIndex(a => a.IbanHash).IsUnique();
// Exactly one primary account per nurse — the filtered-unique backstop the set-primary
// transaction must never trip.
builder.HasIndex(a => a.NurseId)
.IsUnique()
.HasFilter("[IsPrimary] = 1")
.HasDatabaseName("UX_NurseBankAccounts_NurseId_Primary");
builder.HasOne(a => a.Nurse)
.WithMany(n => n.BankAccounts)
.HasForeignKey(a => a.NurseId)
.IsRequired();
builder.HasOne<User>()
.WithMany()
.HasForeignKey(a => a.VerifiedByAdminId)
.IsRequired(false);
}
}
@@ -0,0 +1,34 @@
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.IdentityConfig;
internal sealed class NurseProfileConfig : IEntityTypeConfiguration<NurseProfile>
{
public void Configure(EntityTypeBuilder<NurseProfile> builder)
{
builder.ToTable("NurseProfiles", "usr");
builder.Property(p => p.Bio).HasMaxLength(2000);
builder.Property(p => p.EducationLevel).HasMaxLength(100);
builder.Property(p => p.EducationField).HasMaxLength(150);
builder.Property(p => p.IsVerified).HasDefaultValue(false);
builder.Property(p => p.IsAcceptingBookings).HasDefaultValue(false);
// Read-only quality aggregates — default 0, recomputed by reviews/bookings phases.
builder.Property(p => p.AverageRating).HasPrecision(3, 2).HasDefaultValue(0m);
builder.Property(p => p.TotalReviews).HasDefaultValue(0);
builder.Property(p => p.TotalCompletedBookings).HasDefaultValue(0);
// 1:1 with the owning user.
builder.HasIndex(p => p.UserId).IsUnique();
builder.HasOne(p => p.User)
.WithOne()
.HasForeignKey<NurseProfile>(p => p.UserId)
.IsRequired();
builder.HasQueryFilter(p => p.DeletedAt == null);
}
}
@@ -0,0 +1,29 @@
using Baya.Domain.Entities.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.IdentityConfig;
internal sealed class PatientConfig : IEntityTypeConfiguration<Patient>
{
public void Configure(EntityTypeBuilder<Patient> builder)
{
builder.ToTable("Patients", "usr");
builder.Property(p => p.DisplayName).HasMaxLength(200);
builder.Property(p => p.FirstName).HasMaxLength(100);
builder.Property(p => p.LastName).HasMaxLength(100);
builder.Property(p => p.Gender).HasMaxLength(10).IsRequired();
builder.Property(p => p.BloodType).HasMaxLength(10);
builder.Property(p => p.IsActive).HasDefaultValue(true);
// initial_medical_notes is encrypted at rest (converter wired in ApplicationDbContext).
// Tenancy anchor: every list/get is scoped by CustomerId.
builder.HasIndex(p => p.CustomerId);
builder.HasOne(p => p.Customer)
.WithMany(c => c.Patients)
.HasForeignKey(p => p.CustomerId)
.IsRequired();
}
}
@@ -0,0 +1,215 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class IdentityProfilesPatientsBankAccounts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CustomerProfiles",
schema: "usr",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<int>(type: "int", nullable: false),
DefaultEmergencyContactName = table.Column<string>(type: "nvarchar(max)", nullable: true),
DefaultEmergencyContactPhone = table.Column<string>(type: "nvarchar(max)", 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_CustomerProfiles", x => x.Id);
table.ForeignKey(
name: "FK_CustomerProfiles_Users_UserId",
column: x => x.UserId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "NurseProfiles",
schema: "usr",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<int>(type: "int", nullable: false),
PartnerCenterId = table.Column<long>(type: "bigint", nullable: true),
Bio = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
YearsOfExperience = table.Column<int>(type: "int", nullable: false),
EducationLevel = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
EducationField = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: true),
SpecializationsJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsVerified = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
IsAcceptingBookings = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
AverageRating = table.Column<decimal>(type: "decimal(3,2)", precision: 3, scale: 2, nullable: false, defaultValue: 0m),
TotalReviews = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
TotalCompletedBookings = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
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_NurseProfiles", x => x.Id);
table.ForeignKey(
name: "FK_NurseProfiles_Users_UserId",
column: x => x.UserId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Patients",
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),
DisplayName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
FirstName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
LastName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
BirthDate = table.Column<DateOnly>(type: "date", nullable: false),
Gender = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
BloodType = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
InitialMedicalNotes = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: 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_Patients", x => x.Id);
table.ForeignKey(
name: "FK_Patients_CustomerProfiles_CustomerId",
column: x => x.CustomerId,
principalSchema: "usr",
principalTable: "CustomerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "NurseBankAccounts",
schema: "usr",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
NurseId = table.Column<long>(type: "bigint", nullable: false),
BankName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
AccountHolderName = table.Column<string>(type: "nvarchar(max)", nullable: true),
Iban = table.Column<string>(type: "nvarchar(max)", nullable: true),
IbanHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
IsPrimary = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
MatchedNationalId = table.Column<bool>(type: "bit", nullable: true),
AccountHolderFromBank = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
OwnershipVendorRef = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
IsVerified = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
VerifiedByAdminId = table.Column<int>(type: "int", nullable: true),
VerifiedAt = 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_NurseBankAccounts", x => x.Id);
table.ForeignKey(
name: "FK_NurseBankAccounts_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NurseBankAccounts_Users_VerifiedByAdminId",
column: x => x.VerifiedByAdminId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId");
});
migrationBuilder.CreateIndex(
name: "IX_CustomerProfiles_UserId",
schema: "usr",
table: "CustomerProfiles",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NurseBankAccounts_IbanHash",
schema: "usr",
table: "NurseBankAccounts",
column: "IbanHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NurseBankAccounts_VerifiedByAdminId",
schema: "usr",
table: "NurseBankAccounts",
column: "VerifiedByAdminId");
migrationBuilder.CreateIndex(
name: "UX_NurseBankAccounts_NurseId_Primary",
schema: "usr",
table: "NurseBankAccounts",
column: "NurseId",
unique: true,
filter: "[IsPrimary] = 1");
migrationBuilder.CreateIndex(
name: "IX_NurseProfiles_UserId",
schema: "usr",
table: "NurseProfiles",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Patients_CustomerId",
schema: "usr",
table: "Patients",
column: "CustomerId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NurseBankAccounts",
schema: "usr");
migrationBuilder.DropTable(
name: "Patients",
schema: "usr");
migrationBuilder.DropTable(
name: "NurseProfiles",
schema: "usr");
migrationBuilder.DropTable(
name: "CustomerProfiles",
schema: "usr");
}
}
}
@@ -390,6 +390,266 @@ namespace Baya.Infrastructure.Persistence.Migrations
}); });
}); });
modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("DefaultEmergencyContactName")
.HasColumnType("nvarchar(max)");
b.Property<string>("DefaultEmergencyContactPhone")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("CustomerProfiles", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AccountHolderFromBank")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("AccountHolderName")
.HasColumnType("nvarchar(max)");
b.Property<string>("BankName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("Iban")
.HasColumnType("nvarchar(max)");
b.Property<string>("IbanHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("IsPrimary")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("IsVerified")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool?>("MatchedNationalId")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<string>("OwnershipVendorRef")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset?>("VerifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("VerifiedByAdminId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("IbanHash")
.IsUnique();
b.HasIndex("NurseId")
.IsUnique()
.HasDatabaseName("UX_NurseBankAccounts_NurseId_Primary")
.HasFilter("[IsPrimary] = 1");
b.HasIndex("VerifiedByAdminId");
b.ToTable("NurseBankAccounts", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("AverageRating")
.ValueGeneratedOnAdd()
.HasPrecision(3, 2)
.HasColumnType("decimal(3,2)")
.HasDefaultValue(0m);
b.Property<string>("Bio")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("EducationField")
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<string>("EducationLevel")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<bool>("IsAcceptingBookings")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("IsVerified")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long?>("PartnerCenterId")
.HasColumnType("bigint");
b.Property<string>("SpecializationsJson")
.HasColumnType("nvarchar(max)");
b.Property<int>("TotalCompletedBookings")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<int>("TotalReviews")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<int>("UserId")
.HasColumnType("int");
b.Property<int>("YearsOfExperience")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("NurseProfiles", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateOnly>("BirthDate")
.HasColumnType("date");
b.Property<string>("BloodType")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<long>("CustomerId")
.HasColumnType("bigint");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("FirstName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Gender")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("InitialMedicalNotes")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<string>("LastName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CustomerId");
b.ToTable("Patients", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -880,6 +1140,54 @@ namespace Baya.Infrastructure.Persistence.Migrations
.HasForeignKey("ActorUserId"); .HasForeignKey("ActorUserId");
}); });
modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", "User")
.WithOne()
.HasForeignKey("Baya.Domain.Entities.Identity.CustomerProfile", "UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse")
.WithMany("BankAccounts")
.HasForeignKey("NurseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("VerifiedByAdminId");
b.Navigation("Nurse");
});
modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", "User")
.WithOne()
.HasForeignKey("Baya.Domain.Entities.Identity.NurseProfile", "UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer")
.WithMany("Patients")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Customer");
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{ {
b.HasOne("Baya.Domain.Entities.User.User", null) b.HasOne("Baya.Domain.Entities.User.User", null)
@@ -985,6 +1293,16 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b =>
{
b.Navigation("Patients");
});
modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b =>
{
b.Navigation("BankAccounts");
});
modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => modelBuilder.Entity("Baya.Domain.Entities.User.Role", b =>
{ {
b.Navigation("Claims"); b.Navigation("Claims");
@@ -9,6 +9,10 @@ public class UnitOfWork : IUnitOfWork
public IUserRefreshTokenRepository UserRefreshTokenRepository { get; } public IUserRefreshTokenRepository UserRefreshTokenRepository { get; }
public IUserSessionRepository UserSessionRepository { get; } public IUserSessionRepository UserSessionRepository { get; }
public IUserAccountRepository UserAccountRepository { get; } public IUserAccountRepository UserAccountRepository { get; }
public INurseProfileRepository NurseProfileRepository { get; }
public ICustomerProfileRepository CustomerProfileRepository { get; }
public IPatientRepository PatientRepository { get; }
public INurseBankAccountRepository NurseBankAccountRepository { get; }
public UnitOfWork(ApplicationDbContext db) public UnitOfWork(ApplicationDbContext db)
{ {
@@ -16,6 +20,10 @@ public class UnitOfWork : IUnitOfWork
UserRefreshTokenRepository = new UserRefreshTokenRepository(_db); UserRefreshTokenRepository = new UserRefreshTokenRepository(_db);
UserSessionRepository = new UserSessionRepository(_db); UserSessionRepository = new UserSessionRepository(_db);
UserAccountRepository = new UserAccountRepository(_db); UserAccountRepository = new UserAccountRepository(_db);
NurseProfileRepository = new NurseProfileRepository(_db);
CustomerProfileRepository = new CustomerProfileRepository(_db);
PatientRepository = new PatientRepository(_db);
NurseBankAccountRepository = new NurseBankAccountRepository(_db);
} }
public Task CommitAsync() public Task CommitAsync()
@@ -0,0 +1,32 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class CustomerProfileRepository : BaseAsyncRepository<CustomerProfile>, ICustomerProfileRepository
{
public CustomerProfileRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task<CustomerProfile> GetByUserIdAsync(int userId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(c => c.UserId == userId, cancellationToken);
public Task AddAsync(CustomerProfile profile, CancellationToken cancellationToken)
=> base.AddAsync(profile);
public Task<CustomerProfileDto> GetMineAsync(int userId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(c => c.UserId == userId)
.Select(c => new CustomerProfileDto(c.Id, c.DefaultEmergencyContactName, c.DefaultEmergencyContactPhone))
.FirstOrDefaultAsync(cancellationToken);
public Task<long?> GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(c => c.UserId == userId)
.Select(c => (long?)c.Id)
.FirstOrDefaultAsync(cancellationToken);
}
@@ -0,0 +1,59 @@
using Baya.Application.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class NurseBankAccountRepository : BaseAsyncRepository<NurseBankAccount>, INurseBankAccountRepository
{
public NurseBankAccountRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(NurseBankAccount account, CancellationToken cancellationToken)
=> base.AddAsync(account);
public Task<NurseBankAccount> GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(a => a.Id == id && a.NurseId == nurseId, cancellationToken);
public async Task SetPrimaryAsync(long nurseId, long accountId, CancellationToken cancellationToken)
{
// Clear-then-set inside one transaction: two ordered statements so the filtered unique index is
// never momentarily violated (setting the new primary while the old one is still primary).
await using var transaction = await DbContext.Database.BeginTransactionAsync(cancellationToken);
await Entities
.Where(a => a.NurseId == nurseId && a.IsPrimary)
.ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsPrimary, false), cancellationToken);
await Entities
.Where(a => a.Id == accountId && a.NurseId == nurseId)
.ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsPrimary, true), cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
public Task<bool> IbanHashExistsAsync(string ibanHash, CancellationToken cancellationToken)
=> TableNoTracking.AnyAsync(a => a.IbanHash == ibanHash, cancellationToken);
public Task<bool> HasAnyAsync(long nurseId, CancellationToken cancellationToken)
=> TableNoTracking.AnyAsync(a => a.NurseId == nurseId, cancellationToken);
public async Task<IReadOnlyList<NurseBankAccountDto>> ListAsync(long nurseId, CancellationToken cancellationToken)
{
// Decrypt the IBAN in memory, then mask to last-4 — the full value never leaves the repository.
var rows = await TableNoTracking
.Where(a => a.NurseId == nurseId)
.OrderByDescending(a => a.IsPrimary)
.ThenByDescending(a => a.Id)
.Select(a => new { a.Id, a.BankName, a.Iban, a.IsPrimary, a.IsVerified, a.MatchedNationalId })
.ToListAsync(cancellationToken);
return rows
.Select(a => new NurseBankAccountDto(a.Id, a.BankName, Mask.IbanTail(a.Iban), a.IsPrimary, a.IsVerified, a.MatchedNationalId))
.ToList();
}
}
@@ -0,0 +1,49 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class NurseProfileRepository : BaseAsyncRepository<NurseProfile>, INurseProfileRepository
{
public NurseProfileRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task<NurseProfile> GetByUserIdAsync(int userId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
public Task AddAsync(NurseProfile profile, CancellationToken cancellationToken)
=> base.AddAsync(profile);
public Task<NurseProfileDto> GetMineAsync(int userId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(p => p.UserId == userId)
.Select(p => new NurseProfileDto(
p.Id,
p.Bio,
p.YearsOfExperience,
p.EducationLevel,
p.EducationField,
p.SpecializationsJson,
p.IsVerified,
p.IsAcceptingBookings,
p.AverageRating,
p.TotalReviews,
p.TotalCompletedBookings))
.FirstOrDefaultAsync(cancellationToken);
public Task<long?> GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(p => p.UserId == userId)
.Select(p => (long?)p.Id)
.FirstOrDefaultAsync(cancellationToken);
public Task<NurseIdentityContext> GetIdentityContextByUserIdAsync(int userId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(p => p.UserId == userId)
.Select(p => new NurseIdentityContext(p.Id, p.User.NationalId))
.FirstOrDefaultAsync(cancellationToken);
}
@@ -0,0 +1,60 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class PatientRepository : BaseAsyncRepository<Patient>, IPatientRepository
{
public PatientRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(Patient patient, CancellationToken cancellationToken)
=> base.AddAsync(patient);
public Task<Patient> GetOwnedAsync(long id, long customerId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(p => p.Id == id && p.CustomerId == customerId, cancellationToken);
public async Task<PagedResult<PatientDto>> ListAsync(long customerId, int page, int pageSize, CancellationToken cancellationToken)
{
var query = TableNoTracking.Where(p => p.CustomerId == customerId);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(p => p.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new PatientDto(
p.Id,
p.DisplayName,
p.FirstName,
p.LastName,
p.BirthDate,
p.Gender,
p.BloodType,
p.InitialMedicalNotes,
p.IsActive))
.ToListAsync(cancellationToken);
return new PagedResult<PatientDto>(items, total, page, pageSize);
}
public Task<PatientDto> GetOwnedProjectedAsync(long id, long customerId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(p => p.Id == id && p.CustomerId == customerId)
.Select(p => new PatientDto(
p.Id,
p.DisplayName,
p.FirstName,
p.LastName,
p.BirthDate,
p.Gender,
p.BloodType,
p.InitialMedicalNotes,
p.IsActive))
.FirstOrDefaultAsync(cancellationToken);
}
@@ -0,0 +1,44 @@
using System.Net;
using System.Net.Http.Json;
namespace Baya.Test.Api;
public class CustomerProfilesApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task Upsert_ThenMe_RoundTripsThroughTheEncryptedColumn()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09122000001", "customer");
var upsert = await client.PostAsJsonAsync("/api/v1/customer_profiles/upsert",
new { defaultEmergencyContactName = "Ali", defaultEmergencyContactPhone = "09120000099" });
Assert.Equal(HttpStatusCode.OK, upsert.StatusCode);
// The value survives the encrypt-on-write / decrypt-on-read converter round-trip.
var me = await client.GetAsync("/api/v1/customer_profiles/me");
var data = await AuthTestClient.ReadDataAsync(me);
Assert.Equal("Ali", data.GetProperty("defaultEmergencyContactName").GetString());
Assert.Equal("09120000099", data.GetProperty("defaultEmergencyContactPhone").GetString());
}
[Fact]
public async Task Me_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/customer_profiles/me");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Upsert_InvalidEmergencyPhone_Returns400()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09122000002", "customer");
var response = await client.PostAsJsonAsync("/api/v1/customer_profiles/upsert",
new { defaultEmergencyContactName = "Ali", defaultEmergencyContactPhone = "not-a-phone" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
@@ -0,0 +1,79 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace Baya.Test.Api;
public class NurseBankAccountsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
private const string Iban1 = "IR000000000000000000000001";
private const string Iban2 = "IR000000000000000000000002";
private const string MismatchIban = "IR000000000000000000000000";
private static object AccountBody(string iban) => new { bankName = "Bank Melli", accountHolderName = "Nurse Name", iban };
[Fact]
public async Task Add_List_Duplicate_And_PrimaryFlip()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09124000001", "nurse");
await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert",
new { bio = "b", yearsOfExperience = 5, educationLevel = "", educationField = "", specializationsJson = "[]" });
// First account: ownership inquiry matches, becomes primary, IBAN masked on the wire.
var add1 = await client.PostAsJsonAsync("/api/v1/nurse_bank_accounts/add", AccountBody(Iban1));
Assert.Equal(HttpStatusCode.OK, add1.StatusCode);
var acc1 = await AuthTestClient.ReadDataAsync(add1);
var id1 = acc1.GetProperty("id").GetInt64();
Assert.True(acc1.GetProperty("matchedNationalId").GetBoolean());
Assert.True(acc1.GetProperty("isPrimary").GetBoolean());
var masked = acc1.GetProperty("ibanMasked").GetString()!;
Assert.DoesNotContain(Iban1, masked);
Assert.EndsWith("0001", masked);
// Duplicate IBAN is a clean failure via the iban_hash uniqueness — not an unhandled exception.
var duplicate = await client.PostAsJsonAsync("/api/v1/nurse_bank_accounts/add", AccountBody(Iban1));
Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode);
// Second account: not primary.
var add2 = await client.PostAsJsonAsync("/api/v1/nurse_bank_accounts/add", AccountBody(Iban2));
var acc2 = await AuthTestClient.ReadDataAsync(add2);
var id2 = acc2.GetProperty("id").GetInt64();
Assert.False(acc2.GetProperty("isPrimary").GetBoolean());
// Flip primary to the second account.
var setPrimary = await client.PostAsJsonAsync($"/api/v1/nurse_bank_accounts/set_primary/{id2}", new { });
Assert.Equal(HttpStatusCode.OK, setPrimary.StatusCode);
var list = await client.GetAsync("/api/v1/nurse_bank_accounts/list");
var accounts = (await AuthTestClient.ReadDataAsync(list)).EnumerateArray().ToList();
Assert.Equal(2, accounts.Count);
Assert.True(Primary(accounts, id2));
Assert.False(Primary(accounts, id1));
}
[Fact]
public async Task Add_MismatchIban_RecordsMatchedFalse()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09124000002", "nurse");
await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert",
new { bio = "b", yearsOfExperience = 5, educationLevel = "", educationField = "", specializationsJson = "[]" });
var add = await client.PostAsJsonAsync("/api/v1/nurse_bank_accounts/add", AccountBody(MismatchIban));
Assert.Equal(HttpStatusCode.OK, add.StatusCode);
var data = await AuthTestClient.ReadDataAsync(add);
Assert.False(data.GetProperty("matchedNationalId").GetBoolean());
}
[Fact]
public async Task List_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/nurse_bank_accounts/list");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
private static bool Primary(IEnumerable<JsonElement> accounts, long id) =>
accounts.Single(a => a.GetProperty("id").GetInt64() == id).GetProperty("isPrimary").GetBoolean();
}
@@ -0,0 +1,64 @@
using System.Net;
using System.Net.Http.Json;
namespace Baya.Test.Api;
public class NurseProfilesApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task Upsert_ThenMe_CreatesUnverifiedProfileWithZeroAggregates()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09121000001", "nurse");
var upsert = await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert",
new { bio = "20 years in geriatric care", yearsOfExperience = 20, educationLevel = "BSc", educationField = "Nursing", specializationsJson = "[\"elderly\"]" });
Assert.Equal(HttpStatusCode.OK, upsert.StatusCode);
var me = await client.GetAsync("/api/v1/nurse_profiles/me");
Assert.Equal(HttpStatusCode.OK, me.StatusCode);
var data = await AuthTestClient.ReadDataAsync(me);
Assert.False(data.GetProperty("isVerified").GetBoolean());
Assert.False(data.GetProperty("isAcceptingBookings").GetBoolean());
Assert.Equal(0, data.GetProperty("totalReviews").GetInt32());
Assert.Equal(20, data.GetProperty("yearsOfExperience").GetInt32());
}
[Fact]
public async Task SetAcceptingBookings_TogglesWithoutVerifying()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09121000002", "nurse");
await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert",
new { bio = "b", yearsOfExperience = 3, educationLevel = "", educationField = "", specializationsJson = "[]" });
var toggle = await client.PostAsJsonAsync("/api/v1/nurse_profiles/set_accepting_bookings", new { accepting = true });
Assert.Equal(HttpStatusCode.OK, toggle.StatusCode);
var me = await client.GetAsync("/api/v1/nurse_profiles/me");
var data = await AuthTestClient.ReadDataAsync(me);
Assert.True(data.GetProperty("isAcceptingBookings").GetBoolean());
Assert.False(data.GetProperty("isVerified").GetBoolean());
}
[Fact]
public async Task Me_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/nurse_profiles/me");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Upsert_InvalidExperience_Returns400()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09121000003", "nurse");
var response = await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert",
new { bio = "b", yearsOfExperience = -5, educationLevel = "", educationField = "", specializationsJson = "[]" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
@@ -0,0 +1,90 @@
using System.Net;
using System.Net.Http.Json;
namespace Baya.Test.Api;
public class PatientsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
private static object PatientBody(string name, string gender = "female") => new
{
displayName = name,
firstName = "F",
lastName = "L",
birthDate = "1950-03-01",
gender,
bloodType = "O+",
initialMedicalNotes = "diabetic"
};
[Fact]
public async Task Create_List_Get_Update_Archive_Lifecycle()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09123000001", "customer");
var create = await client.PostAsJsonAsync("/api/v1/patients/create", PatientBody("Mother"));
Assert.Equal(HttpStatusCode.OK, create.StatusCode);
var created = await AuthTestClient.ReadDataAsync(create);
var id = created.GetProperty("id").GetInt64();
Assert.Equal("female", created.GetProperty("gender").GetString());
Assert.Equal("diabetic", created.GetProperty("initialMedicalNotes").GetString());
var list = await client.GetAsync("/api/v1/patients/list");
var listData = await AuthTestClient.ReadDataAsync(list);
Assert.Equal(1, listData.GetProperty("total").GetInt32());
var get = await client.GetAsync($"/api/v1/patients/get/{id}");
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
var update = await client.PostAsJsonAsync($"/api/v1/patients/update/{id}", PatientBody("Mother Renamed", "female"));
Assert.Equal(HttpStatusCode.OK, update.StatusCode);
var updated = await AuthTestClient.ReadDataAsync(update);
Assert.Equal("Mother Renamed", updated.GetProperty("displayName").GetString());
var archive = await client.PostAsJsonAsync($"/api/v1/patients/archive/{id}", new { });
Assert.Equal(HttpStatusCode.OK, archive.StatusCode);
var afterArchive = await client.GetAsync($"/api/v1/patients/get/{id}");
var archivedData = await AuthTestClient.ReadDataAsync(afterArchive);
Assert.False(archivedData.GetProperty("isActive").GetBoolean());
}
[Fact]
public async Task Get_And_Update_OfAnotherCustomersPatient_Return404()
{
// Customer A creates a patient.
var clientA = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, clientA, "09123000002", "customer");
var create = await clientA.PostAsJsonAsync("/api/v1/patients/create", PatientBody("A's patient"));
var aPatientId = (await AuthTestClient.ReadDataAsync(create)).GetProperty("id").GetInt64();
// Customer B can neither read nor mutate A's patient — existence is not leaked.
var clientB = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, clientB, "09123000003", "customer");
var get = await clientB.GetAsync($"/api/v1/patients/get/{aPatientId}");
Assert.Equal(HttpStatusCode.NotFound, get.StatusCode);
var update = await clientB.PostAsJsonAsync($"/api/v1/patients/update/{aPatientId}", PatientBody("hijack"));
Assert.Equal(HttpStatusCode.NotFound, update.StatusCode);
}
[Fact]
public async Task List_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/patients/list");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Create_MissingGender_Returns400()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09123000004", "customer");
var response = await client.PostAsJsonAsync("/api/v1/patients/create", PatientBody("No gender", gender: ""));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
@@ -0,0 +1,27 @@
using System.Net.Http.Json;
namespace Baya.Test.Api;
/// <summary>
/// Logs a user in, grants a public role, then refreshes so the bearer token carries the new role claim
/// (role claims are baked into the access token at mint time — see backend-phase-2). Uses one OTP verify
/// plus one refresh per call to stay inside the OTP endpoint's per-IP rate-limit budget.
/// </summary>
internal static class ProfileTestClient
{
public static async Task AuthenticateAsync(BayaApiFactory factory, HttpClient client, string phone, string role)
{
var tokens = await AuthTestClient.LoginAsync(factory, client, phone);
AuthTestClient.UseBearer(client, tokens.GetProperty("accessToken").GetString()!);
var select = await client.PostAsJsonAsync("/api/v1/me/select_role", new { role });
select.EnsureSuccessStatusCode();
var refreshToken = tokens.GetProperty("refreshToken").GetString()!;
var refreshed = await client.PostAsJsonAsync("/api/v1/auth/refresh", new { refreshToken });
refreshed.EnsureSuccessStatusCode();
var data = await AuthTestClient.ReadDataAsync(refreshed);
AuthTestClient.UseBearer(client, data.GetProperty("accessToken").GetString()!);
}
}
@@ -0,0 +1,127 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Identity.Commands.AddNurseBankAccount;
using Baya.Application.Features.Identity.Commands.SetPrimaryBankAccount;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
namespace Baya.Test.Foundation.Identity;
public class NurseBankAccountHandlersTests
{
private const string ValidIban = "IR062960000000100324200001";
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
private readonly INurseBankAccountRepository _accounts = Substitute.For<INurseBankAccountRepository>();
private readonly IFieldEncryptor _encryptor = Substitute.For<IFieldEncryptor>();
private readonly IBankAccountOwnershipVerifier _verifier = Substitute.For<IBankAccountOwnershipVerifier>();
public NurseBankAccountHandlersTests()
{
_currentUser.UserId.Returns(7);
_currentUser.Roles.Returns([RoleNames.Nurse]);
_unitOfWork.NurseProfileRepository.Returns(_nurses);
_unitOfWork.NurseBankAccountRepository.Returns(_accounts);
_nurses.GetIdentityContextByUserIdAsync(7, Arg.Any<CancellationToken>())
.Returns(new NurseIdentityContext(42L, "0012345678"));
_encryptor.Hash(Arg.Any<string>()).Returns(ci => "HASH-" + ci.Arg<string>());
}
private AddNurseBankAccountCommandHandler CreateAddHandler() =>
new(_currentUser, _unitOfWork, _encryptor, _verifier);
[Fact]
public async Task Add_MatchingIban_RunsInquiryAndSetsMatchedTrueAndPrimary()
{
_accounts.IbanHashExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(false);
_accounts.HasAnyAsync(42L, Arg.Any<CancellationToken>()).Returns(false);
_verifier.VerifyOwnershipAsync(Arg.Any<string>(), "0012345678", Arg.Any<CancellationToken>())
.Returns(new OwnershipInquiryResult(true, "Verified Holder", "MOCK-SHEBA-ABC"));
var handler = CreateAddHandler();
var result = await handler.Handle(new AddNurseBankAccountCommand("Bank Melli", "Nurse Name", ValidIban), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.True(result.Result.MatchedNationalId);
Assert.True(result.Result.IsPrimary);
Assert.DoesNotContain(ValidIban, result.Result.IbanMasked);
await _verifier.Received(1).VerifyOwnershipAsync(ValidIban, "0012345678", Arg.Any<CancellationToken>());
await _accounts.Received(1).AddAsync(
Arg.Is<NurseBankAccount>(a => a.NurseId == 42L && a.MatchedNationalId == true && a.OwnershipVendorRef == "MOCK-SHEBA-ABC" && a.IbanHash == "HASH-" + ValidIban),
Arg.Any<CancellationToken>());
await _unitOfWork.Received(1).CommitAsync();
}
[Fact]
public async Task Add_MismatchIban_RecordsMatchedFalse()
{
_accounts.IbanHashExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(false);
_accounts.HasAnyAsync(42L, Arg.Any<CancellationToken>()).Returns(true);
_verifier.VerifyOwnershipAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new OwnershipInquiryResult(false, "Someone Else", "MOCK-SHEBA-XYZ"));
var handler = CreateAddHandler();
var result = await handler.Handle(new AddNurseBankAccountCommand("Bank Melli", "Nurse Name", ValidIban), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.False(result.Result.MatchedNationalId);
Assert.False(result.Result.IsPrimary);
}
[Fact]
public async Task Add_DuplicateIban_IsRejectedBeforeInsert()
{
_accounts.IbanHashExistsAsync("HASH-" + ValidIban, Arg.Any<CancellationToken>()).Returns(true);
var handler = CreateAddHandler();
var result = await handler.Handle(new AddNurseBankAccountCommand("Bank Melli", "Nurse Name", ValidIban), CancellationToken.None);
Assert.False(result.IsSuccess);
await _accounts.DidNotReceive().AddAsync(Arg.Any<NurseBankAccount>(), Arg.Any<CancellationToken>());
await _verifier.DidNotReceive().VerifyOwnershipAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
await _unitOfWork.DidNotReceive().CommitAsync();
}
[Fact]
public async Task Add_NoNurseProfile_IsFailure()
{
_nurses.GetIdentityContextByUserIdAsync(7, Arg.Any<CancellationToken>()).ReturnsNull();
var handler = CreateAddHandler();
var result = await handler.Handle(new AddNurseBankAccountCommand("Bank Melli", "Nurse Name", ValidIban), CancellationToken.None);
Assert.False(result.IsSuccess);
await _accounts.DidNotReceive().AddAsync(Arg.Any<NurseBankAccount>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task SetPrimary_OwnedNonPrimary_FlipsAtomically()
{
_nurses.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
_accounts.GetOwnedAsync(5L, 42L, Arg.Any<CancellationToken>()).Returns(new NurseBankAccount { NurseId = 42L, IsPrimary = false });
var handler = new SetPrimaryBankAccountCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new SetPrimaryBankAccountCommand(5L), CancellationToken.None);
Assert.True(result.IsSuccess);
await _accounts.Received(1).SetPrimaryAsync(42L, 5L, Arg.Any<CancellationToken>());
}
[Fact]
public async Task SetPrimary_NotOwned_IsNotFound()
{
_nurses.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
_accounts.GetOwnedAsync(5L, 42L, Arg.Any<CancellationToken>()).ReturnsNull();
var handler = new SetPrimaryBankAccountCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new SetPrimaryBankAccountCommand(5L), CancellationToken.None);
Assert.True(result.IsNotFound);
await _accounts.DidNotReceive().SetPrimaryAsync(Arg.Any<long>(), Arg.Any<long>(), Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,83 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings;
using Baya.Application.Features.Identity.Commands.UpsertNurseProfile;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
namespace Baya.Test.Foundation.Identity;
public class NurseProfileHandlersTests
{
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly INurseProfileRepository _repo = Substitute.For<INurseProfileRepository>();
public NurseProfileHandlersTests()
{
_currentUser.UserId.Returns(7);
_currentUser.Roles.Returns([RoleNames.Nurse]);
_unitOfWork.NurseProfileRepository.Returns(_repo);
}
[Fact]
public async Task Upsert_NoExistingProfile_CreatesUnverifiedAndCommits()
{
_repo.GetByUserIdAsync(7, Arg.Any<CancellationToken>()).ReturnsNull();
_repo.GetMineAsync(7, Arg.Any<CancellationToken>())
.Returns(new NurseProfileDto(1, "bio", 3, "BSc", "Nursing", "[]", false, false, 0m, 0, 0));
var handler = new UpsertNurseProfileCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new UpsertNurseProfileCommand("bio", 3, "BSc", "Nursing", "[]"), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.False(result.Result.IsVerified);
await _repo.Received(1).AddAsync(
Arg.Is<NurseProfile>(p => p.UserId == 7 && !p.IsVerified && !p.IsAcceptingBookings),
Arg.Any<CancellationToken>());
await _unitOfWork.Received(1).CommitAsync();
}
[Fact]
public async Task Upsert_NonNurseRole_IsForbidden()
{
_currentUser.Roles.Returns([RoleNames.Customer]);
var handler = new UpsertNurseProfileCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new UpsertNurseProfileCommand("bio", 3, "BSc", "Nursing", "[]"), CancellationToken.None);
Assert.True(result.IsForbidden);
await _repo.DidNotReceive().AddAsync(Arg.Any<NurseProfile>(), Arg.Any<CancellationToken>());
await _unitOfWork.DidNotReceive().CommitAsync();
}
[Fact]
public async Task SetAcceptingBookings_NoProfile_IsNotFound()
{
_repo.GetByUserIdAsync(7, Arg.Any<CancellationToken>()).ReturnsNull();
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new SetNurseAcceptingBookingsCommand(true), CancellationToken.None);
Assert.True(result.IsNotFound);
await _unitOfWork.DidNotReceive().CommitAsync();
}
[Fact]
public async Task SetAcceptingBookings_ExistingProfile_TogglesWithoutTouchingVerified()
{
var profile = new NurseProfile { UserId = 7 };
_repo.GetByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(profile);
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new SetNurseAcceptingBookingsCommand(true), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.True(profile.IsAcceptingBookings);
Assert.False(profile.IsVerified);
await _unitOfWork.Received(1).CommitAsync();
}
}
@@ -0,0 +1,101 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Identity.Commands.CreatePatient;
using Baya.Application.Features.Identity.Commands.UpdatePatient;
using Baya.Application.Features.Identity.Queries.GetPatient;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
namespace Baya.Test.Foundation.Identity;
public class PatientHandlersTests
{
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly ICustomerProfileRepository _customers = Substitute.For<ICustomerProfileRepository>();
private readonly IPatientRepository _patients = Substitute.For<IPatientRepository>();
public PatientHandlersTests()
{
_currentUser.UserId.Returns(7);
_currentUser.Roles.Returns([RoleNames.Customer]);
_unitOfWork.CustomerProfileRepository.Returns(_customers);
_unitOfWork.PatientRepository.Returns(_patients);
}
[Fact]
public async Task Create_UnderExistingCustomer_UsesResolvedCustomerId()
{
_customers.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
var handler = new CreatePatientCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(
new CreatePatientCommand("Mother", "A", "B", new DateOnly(1950, 1, 1), "female", "O+", "notes"),
CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal("female", result.Result.Gender);
await _patients.Received(1).AddAsync(Arg.Is<Patient>(p => p.CustomerId == 42L && p.IsActive), Arg.Any<CancellationToken>());
await _customers.DidNotReceive().AddAsync(Arg.Any<CustomerProfile>(), Arg.Any<CancellationToken>());
await _unitOfWork.Received(1).CommitAsync();
}
[Fact]
public async Task Create_NoCustomerProfileYet_AutoProvisionsProfile()
{
_customers.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns((long?)null);
var handler = new CreatePatientCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(
new CreatePatientCommand("Mother", "A", "B", new DateOnly(1950, 1, 1), "female", "O+", "notes"),
CancellationToken.None);
Assert.True(result.IsSuccess);
await _customers.Received(1).AddAsync(Arg.Is<CustomerProfile>(c => c.UserId == 7), Arg.Any<CancellationToken>());
await _patients.Received(1).AddAsync(Arg.Any<Patient>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Create_NonCustomerRole_IsForbidden()
{
_currentUser.Roles.Returns([RoleNames.Nurse]);
var handler = new CreatePatientCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(
new CreatePatientCommand("Mother", "A", "B", new DateOnly(1950, 1, 1), "female", "O+", "notes"),
CancellationToken.None);
Assert.True(result.IsForbidden);
await _patients.DidNotReceive().AddAsync(Arg.Any<Patient>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Update_OtherCustomersPatient_IsNotFound()
{
// Tenancy: the repo scopes by customerId, so a non-owned patient resolves to null → not-found.
_customers.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
_patients.GetOwnedAsync(99L, 42L, Arg.Any<CancellationToken>()).ReturnsNull();
var handler = new UpdatePatientCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(
new UpdatePatientCommand(99L, "X", "A", "B", new DateOnly(1960, 5, 5), "male", null, null),
CancellationToken.None);
Assert.True(result.IsNotFound);
await _unitOfWork.DidNotReceive().CommitAsync();
}
[Fact]
public async Task Get_OtherCustomersPatient_IsNotFound()
{
_customers.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
_patients.GetOwnedProjectedAsync(99L, 42L, Arg.Any<CancellationToken>()).ReturnsNull();
var handler = new GetPatientQueryHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new GetPatientQuery(99L), CancellationToken.None);
Assert.True(result.IsNotFound);
}
}