@
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:
@@ -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 | استعلام شبا IBAN↔national-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
@@ -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();
|
||||||
}
|
}
|
||||||
+67
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -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>>;
|
||||||
+34
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -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>>;
|
||||||
+63
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -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>>;
|
||||||
+30
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -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>>;
|
||||||
+36
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -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>>;
|
||||||
+48
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -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>>;
|
||||||
+51
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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>>;
|
||||||
+47
@@ -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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -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).");
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -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>>;
|
||||||
+51
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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);
|
||||||
|
}
|
||||||
+23
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -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>>;
|
||||||
+23
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -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>>;
|
||||||
+28
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -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>>;
|
||||||
+29
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -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>>>;
|
||||||
+28
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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);
|
||||||
+22
-4
@@ -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; }
|
||||||
|
}
|
||||||
+38
@@ -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
|
||||||
|
|||||||
+4
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+22
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1333
File diff suppressed because it is too large
Load Diff
+215
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+318
@@ -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");
|
||||||
|
|||||||
+8
@@ -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()
|
||||||
|
|||||||
+32
@@ -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);
|
||||||
|
}
|
||||||
+59
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -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);
|
||||||
|
}
|
||||||
+60
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user