From 39a979b1a74ba97cf7f5414994582a8cd2d95c33 Mon Sep 17 00:00:00 2001 From: hamid Date: Thu, 2 Jul 2026 12:03:15 +0330 Subject: [PATCH] @ backend phase 3: identity profiles, patients & nurse bank accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) @ --- dev/contracts/domains/identity-profiles.md | 87 + dev/contracts/openapi/swagger.v1.json | 1494 ++++++++++++++++- dev/shared-working-context/backend/STATUS.md | 22 + .../backend/handoff/after-backend-phase-3.md | 57 + .../reports/backend-phase-3-report.md | 69 + .../reports/mocks-registry.md | 12 +- .../data-model/01-identity-and-access.html | 4 + product/data-model/01-identity-and-access.md | 10 + server/CLAUDE.md | 24 +- .../V1/CustomerProfilesController.cs | 30 + .../V1/NurseBankAccountsController.cs | 48 + .../Controllers/V1/NurseProfilesController.cs | 36 + .../Controllers/V1/PatientsController.cs | 49 + .../src/Core/Baya.Application/Common/Mask.cs | 12 + .../Common/IBankAccountOwnershipVerifier.cs | 23 + .../Persistence/ICustomerProfileRepository.cs | 21 + .../INurseBankAccountRepository.cs | 31 + .../Persistence/INurseProfileRepository.cs | 25 + .../Persistence/IPatientRepository.cs | 22 + .../Contracts/Persistence/IUnitOfWork.cs | 6 +- .../AddNurseBankAccountCommand.Handler.cs | 67 + .../AddNurseBankAccountCommand.Validator.cs | 16 + .../AddNurseBankAccountCommand.cs | 15 + .../ArchivePatientCommand.Handler.cs | 34 + .../ArchivePatient/ArchivePatientCommand.cs | 8 + .../CreatePatientCommand.Handler.cs | 63 + .../CreatePatientCommand.Validator.cs | 21 + .../CreatePatient/CreatePatientCommand.cs | 19 + ...etNurseAcceptingBookingsCommand.Handler.cs | 30 + .../SetNurseAcceptingBookingsCommand.cs | 7 + .../SetPrimaryBankAccountCommand.Handler.cs | 36 + .../SetPrimaryBankAccountCommand.cs | 7 + ...kAccountOwnershipInquiryCommand.Handler.cs | 48 + ...iggerBankAccountOwnershipInquiryCommand.cs | 12 + .../UpdatePatientCommand.Handler.cs | 51 + .../UpdatePatientCommand.Validator.cs | 22 + .../UpdatePatient/UpdatePatientCommand.cs | 16 + .../UpsertCustomerProfileCommand.Handler.cs | 47 + .../UpsertCustomerProfileCommand.Validator.cs | 15 + .../UpsertCustomerProfileCommand.cs | 13 + .../UpsertNurseProfileCommand.Handler.cs | 51 + .../UpsertNurseProfileCommand.Validator.cs | 14 + .../UpsertNurseProfileCommand.cs | 16 + .../Features/Identity/PatientRules.cs | 9 + .../GetMyCustomerProfileQuery.Handler.cs | 23 + .../GetMyCustomerProfileQuery.cs | 8 + .../GetMyNurseProfileQuery.Handler.cs | 23 + .../GetMyNurseProfileQuery.cs | 8 + .../GetPatient/GetPatientQuery.Handler.cs | 28 + .../Queries/GetPatient/GetPatientQuery.cs | 8 + .../ListNurseBankAccountsQuery.Handler.cs | 29 + .../ListNurseBankAccountsQuery.cs | 8 + .../ListPatients/ListPatientsQuery.Handler.cs | 28 + .../Queries/ListPatients/ListPatientsQuery.cs | 9 + .../Features/Identity/Sheba.cs | 27 + .../Models/Identity/CustomerProfileDto.cs | 10 + .../Models/Identity/NurseBankAccountDto.cs | 13 + .../Models/Identity/NurseIdentityContext.cs | 10 + .../Models/Identity/NurseProfileDto.cs | 18 + .../Models/Identity/PatientDto.cs | 16 + .../ServiceCollectionExtension.cs | 28 +- .../Entities/Identity/CustomerProfile.cs | 22 + .../Entities/Identity/NurseBankAccount.cs | 53 + .../Entities/Identity/NurseProfile.cs | 54 + .../Baya.Domain/Entities/Identity/Patient.cs | 32 + .../Seams/MockBankAccountOwnershipVerifier.cs | 38 + .../Seams/SeamOptions.cs | 18 + .../ServiceCollectionExtension.cs | 4 + .../ApplicationDbContext.cs | 18 + .../IdentityConfig/CustomerProfileConfig.cs | 22 + .../IdentityConfig/NurseBankAccountConfig.cs | 43 + .../IdentityConfig/NurseProfileConfig.cs | 34 + .../IdentityConfig/PatientConfig.cs | 29 + ...tyProfilesPatientsBankAccounts.Designer.cs | 1333 +++++++++++++++ ...31_IdentityProfilesPatientsBankAccounts.cs | 215 +++ .../ApplicationDbContextModelSnapshot.cs | 318 ++++ .../Repositories/Common/UnitOfWork.cs | 12 +- .../Repositories/CustomerProfileRepository.cs | 32 + .../NurseBankAccountRepository.cs | 59 + .../Repositories/NurseProfileRepository.cs | 49 + .../Repositories/PatientRepository.cs | 60 + .../Baya.Test.Api/CustomerProfilesApiTests.cs | 44 + .../NurseBankAccountsApiTests.cs | 79 + .../Baya.Test.Api/NurseProfilesApiTests.cs | 64 + .../Tests/Baya.Test.Api/PatientsApiTests.cs | 90 + .../Tests/Baya.Test.Api/ProfileTestClient.cs | 27 + .../Identity/NurseBankAccountHandlersTests.cs | 127 ++ .../Identity/NurseProfileHandlersTests.cs | 83 + .../Identity/PatientHandlersTests.cs | 101 ++ 89 files changed, 6060 insertions(+), 13 deletions(-) create mode 100644 dev/contracts/domains/identity-profiles.md create mode 100644 dev/shared-working-context/backend/handoff/after-backend-phase-3.md create mode 100644 dev/shared-working-context/reports/backend-phase-3-report.md create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/CustomerProfilesController.cs create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/NurseBankAccountsController.cs create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/NurseProfilesController.cs create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/PatientsController.cs create mode 100644 server/src/Core/Baya.Application/Common/Mask.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Common/IBankAccountOwnershipVerifier.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Persistence/ICustomerProfileRepository.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Persistence/INurseBankAccountRepository.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Persistence/INurseProfileRepository.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Persistence/IPatientRepository.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/AddNurseBankAccount/AddNurseBankAccountCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/AddNurseBankAccount/AddNurseBankAccountCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/AddNurseBankAccount/AddNurseBankAccountCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/ArchivePatient/ArchivePatientCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/ArchivePatient/ArchivePatientCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/CreatePatient/CreatePatientCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/CreatePatient/CreatePatientCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/CreatePatient/CreatePatientCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/SetNurseAcceptingBookings/SetNurseAcceptingBookingsCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/SetNurseAcceptingBookings/SetNurseAcceptingBookingsCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/SetPrimaryBankAccount/SetPrimaryBankAccountCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/SetPrimaryBankAccount/SetPrimaryBankAccountCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/TriggerBankAccountOwnershipInquiry/TriggerBankAccountOwnershipInquiryCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/TriggerBankAccountOwnershipInquiry/TriggerBankAccountOwnershipInquiryCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/UpdatePatient/UpdatePatientCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/UpdatePatient/UpdatePatientCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/UpdatePatient/UpdatePatientCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/UpsertCustomerProfile/UpsertCustomerProfileCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/UpsertCustomerProfile/UpsertCustomerProfileCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/UpsertCustomerProfile/UpsertCustomerProfileCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/UpsertNurseProfile/UpsertNurseProfileCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/UpsertNurseProfile/UpsertNurseProfileCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Commands/UpsertNurseProfile/UpsertNurseProfileCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/PatientRules.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Queries/GetMyCustomerProfile/GetMyCustomerProfileQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Queries/GetMyCustomerProfile/GetMyCustomerProfileQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Queries/GetMyNurseProfile/GetMyNurseProfileQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Queries/GetMyNurseProfile/GetMyNurseProfileQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Queries/GetPatient/GetPatientQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Queries/GetPatient/GetPatientQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Queries/ListNurseBankAccounts/ListNurseBankAccountsQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Queries/ListNurseBankAccounts/ListNurseBankAccountsQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Queries/ListPatients/ListPatientsQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Queries/ListPatients/ListPatientsQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Identity/Sheba.cs create mode 100644 server/src/Core/Baya.Application/Models/Identity/CustomerProfileDto.cs create mode 100644 server/src/Core/Baya.Application/Models/Identity/NurseBankAccountDto.cs create mode 100644 server/src/Core/Baya.Application/Models/Identity/NurseIdentityContext.cs create mode 100644 server/src/Core/Baya.Application/Models/Identity/NurseProfileDto.cs create mode 100644 server/src/Core/Baya.Application/Models/Identity/PatientDto.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Identity/CustomerProfile.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Identity/NurseBankAccount.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Identity/NurseProfile.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Identity/Patient.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBankAccountOwnershipVerifier.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/CustomerProfileConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/NurseBankAccountConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/NurseProfileConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/PatientConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702042131_IdentityProfilesPatientsBankAccounts.Designer.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702042131_IdentityProfilesPatientsBankAccounts.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CustomerProfileRepository.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseBankAccountRepository.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseProfileRepository.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PatientRepository.cs create mode 100644 server/src/Tests/Baya.Test.Api/CustomerProfilesApiTests.cs create mode 100644 server/src/Tests/Baya.Test.Api/NurseBankAccountsApiTests.cs create mode 100644 server/src/Tests/Baya.Test.Api/NurseProfilesApiTests.cs create mode 100644 server/src/Tests/Baya.Test.Api/PatientsApiTests.cs create mode 100644 server/src/Tests/Baya.Test.Api/ProfileTestClient.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Identity/NurseBankAccountHandlersTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Identity/NurseProfileHandlersTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Identity/PatientHandlersTests.cs diff --git a/dev/contracts/domains/identity-profiles.md b/dev/contracts/domains/identity-profiles.md new file mode 100644 index 0000000..cf0c4d3 --- /dev/null +++ b/dev/contracts/domains/identity-profiles.md @@ -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` (`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). diff --git a/dev/contracts/openapi/swagger.v1.json b/dev/contracts/openapi/swagger.v1.json index b8be61f..89007e8 100644 --- a/dev/contracts/openapi/swagger.v1.json +++ b/dev/contracts/openapi/swagger.v1.json @@ -7,7 +7,7 @@ }, "servers": [ { - "url": "http://localhost:5188" + "url": "http://localhost" } ], "paths": { @@ -412,6 +412,148 @@ ] } }, + "/api/v1/customer_profiles/upsert": { + "post": { + "tags": [ + "CustomerProfiles" + ], + "operationId": "CustomerProfiles_Upsert", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertCustomerProfileCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfCustomerProfileDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/customer_profiles/me": { + "get": { + "tags": [ + "CustomerProfiles" + ], + "operationId": "CustomerProfiles_Me", + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfCustomerProfileDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/holidays/get_holidays": { "get": { "tags": [ @@ -1111,6 +1253,932 @@ ] } }, + "/api/v1/nurse_bank_accounts/add": { + "post": { + "tags": [ + "NurseBankAccounts" + ], + "operationId": "NurseBankAccounts_Add", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddNurseBankAccountCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfNurseBankAccountDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/nurse_bank_accounts/set_primary/{id}": { + "post": { + "tags": [ + "NurseBankAccounts" + ], + "operationId": "NurseBankAccounts_SetPrimary", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/nurse_bank_accounts/list": { + "get": { + "tags": [ + "NurseBankAccounts" + ], + "operationId": "NurseBankAccounts_List", + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfIReadOnlyListOfNurseBankAccountDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/nurse_bank_accounts/verify_ownership/{id}": { + "post": { + "tags": [ + "NurseBankAccounts" + ], + "operationId": "NurseBankAccounts_VerifyOwnership", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfNurseBankAccountDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/nurse_profiles/upsert": { + "post": { + "tags": [ + "NurseProfiles" + ], + "operationId": "NurseProfiles_Upsert", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertNurseProfileCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfNurseProfileDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/nurse_profiles/set_accepting_bookings": { + "post": { + "tags": [ + "NurseProfiles" + ], + "operationId": "NurseProfiles_SetAcceptingBookings", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetNurseAcceptingBookingsCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/nurse_profiles/me": { + "get": { + "tags": [ + "NurseProfiles" + ], + "operationId": "NurseProfiles_Me", + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfNurseProfileDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/patients/create": { + "post": { + "tags": [ + "Patients" + ], + "summary": "Creates a Patient", + "operationId": "Patients_Create", + "requestBody": { + "x-name": "command", + "description": "A Patient representation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePatientCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPatientDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/patients/list": { + "get": { + "tags": [ + "Patients" + ], + "operationId": "Patients_List", + "parameters": [ + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 2 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPagedResultOfPatientDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/patients/get/{id}": { + "get": { + "tags": [ + "Patients" + ], + "summary": "Retrieves a Patient by unique id", + "operationId": "Patients_Get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "A unique id for the Patient", + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPatientDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/patients/update/{id}": { + "post": { + "tags": [ + "Patients" + ], + "summary": "Updates a Patient by unique id", + "operationId": "Patients_Update", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "A Patient representation", + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePatientCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPatientDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/patients/archive/{id}": { + "post": { + "tags": [ + "Patients" + ], + "operationId": "Patients_Archive", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/ping/get_status": { "get": { "tags": [ @@ -2043,6 +3111,59 @@ } } }, + "ApiResultOfCustomerProfileDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/CustomerProfileDto" + } + ] + } + } + } + ] + }, + "CustomerProfileDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "defaultEmergencyContactName": { + "type": "string", + "nullable": true + }, + "defaultEmergencyContactPhone": { + "type": "string", + "nullable": true + } + } + }, + "UpsertCustomerProfileCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "defaultEmergencyContactName": { + "type": "string", + "nullable": true + }, + "defaultEmergencyContactPhone": { + "type": "string", + "nullable": true + } + } + }, "ApiResultOfPagedResultOfHolidayDto": { "allOf": [ { @@ -2342,6 +3463,377 @@ } } }, + "ApiResultOfNurseBankAccountDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/NurseBankAccountDto" + } + ] + } + } + } + ] + }, + "NurseBankAccountDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "bankName": { + "type": "string", + "nullable": true + }, + "ibanMasked": { + "type": "string", + "nullable": true + }, + "isPrimary": { + "type": "boolean" + }, + "isVerified": { + "type": "boolean" + }, + "matchedNationalId": { + "type": "boolean", + "nullable": true + } + } + }, + "AddNurseBankAccountCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "bankName": { + "type": "string", + "nullable": true + }, + "accountHolderName": { + "type": "string", + "nullable": true + }, + "iban": { + "type": "string", + "nullable": true + } + } + }, + "ApiResultOfIReadOnlyListOfNurseBankAccountDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/NurseBankAccountDto" + } + } + } + } + ] + }, + "ApiResultOfNurseProfileDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/NurseProfileDto" + } + ] + } + } + } + ] + }, + "NurseProfileDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "bio": { + "type": "string", + "nullable": true + }, + "yearsOfExperience": { + "type": "integer", + "format": "int32" + }, + "educationLevel": { + "type": "string", + "nullable": true + }, + "educationField": { + "type": "string", + "nullable": true + }, + "specializationsJson": { + "type": "string", + "nullable": true + }, + "isVerified": { + "type": "boolean" + }, + "isAcceptingBookings": { + "type": "boolean" + }, + "averageRating": { + "type": "number", + "format": "decimal" + }, + "totalReviews": { + "type": "integer", + "format": "int32" + }, + "totalCompletedBookings": { + "type": "integer", + "format": "int32" + } + } + }, + "UpsertNurseProfileCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "bio": { + "type": "string", + "nullable": true + }, + "yearsOfExperience": { + "type": "integer", + "format": "int32" + }, + "educationLevel": { + "type": "string", + "nullable": true + }, + "educationField": { + "type": "string", + "nullable": true + }, + "specializationsJson": { + "type": "string", + "nullable": true + } + } + }, + "SetNurseAcceptingBookingsCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "accepting": { + "type": "boolean" + } + } + }, + "ApiResultOfPatientDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PatientDto" + } + ] + } + } + } + ] + }, + "PatientDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "displayName": { + "type": "string", + "nullable": true + }, + "firstName": { + "type": "string", + "nullable": true + }, + "lastName": { + "type": "string", + "nullable": true + }, + "birthDate": { + "type": "string", + "format": "date" + }, + "gender": { + "type": "string", + "nullable": true + }, + "bloodType": { + "type": "string", + "nullable": true + }, + "initialMedicalNotes": { + "type": "string", + "nullable": true + }, + "isActive": { + "type": "boolean" + } + } + }, + "CreatePatientCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "displayName": { + "type": "string", + "nullable": true + }, + "firstName": { + "type": "string", + "nullable": true + }, + "lastName": { + "type": "string", + "nullable": true + }, + "birthDate": { + "type": "string", + "format": "date" + }, + "gender": { + "type": "string", + "nullable": true + }, + "bloodType": { + "type": "string", + "nullable": true + }, + "initialMedicalNotes": { + "type": "string", + "nullable": true + } + } + }, + "ApiResultOfPagedResultOfPatientDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfPatientDto" + } + ] + } + } + } + ] + }, + "PagedResultOfPatientDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/PatientDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "UpdatePatientCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "displayName": { + "type": "string", + "nullable": true + }, + "firstName": { + "type": "string", + "nullable": true + }, + "lastName": { + "type": "string", + "nullable": true + }, + "birthDate": { + "type": "string", + "format": "date" + }, + "gender": { + "type": "string", + "nullable": true + }, + "bloodType": { + "type": "string", + "nullable": true + }, + "initialMedicalNotes": { + "type": "string", + "nullable": true + } + } + }, "ApiResultOfPingQueryResult": { "allOf": [ { diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index bc7ff78..1b6e6cb 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,28 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## 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` — 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 - **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; diff --git a/dev/shared-working-context/backend/handoff/after-backend-phase-3.md b/dev/shared-working-context/backend/handoff/after-backend-phase-3.md new file mode 100644 index 0000000..d64e0aa --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-backend-phase-3.md @@ -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` 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). diff --git a/dev/shared-working-context/reports/backend-phase-3-report.md b/dev/shared-working-context/reports/backend-phase-3-report.md new file mode 100644 index 0000000..92e0afd --- /dev/null +++ b/dev/shared-working-context/reports/backend-phase-3-report.md @@ -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` as `IValidator` + 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. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index 7c1cca8..e40e33e 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -26,7 +26,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 | `IShahkarVerifier` | backend-phase-6 | Phone↔national-id match — fake pass | _tbd_ | Real Shahkar/KYC vendor; persist `external_response_json` | 🔴 | | `IIdentityKycProvider` | backend-phase-6 | National-ID + liveness — fake pass | _tbd_ | Finnotech/U-ID/Jibbit/Verify liveness+OCR | 🔴 | | `ICredentialVerifier` | backend-phase-6 | MoH/INO/criminal-record — manual/fake | _tbd_ | Manual admin today; API when a portal appears (`verification_method=api`) | 🔴 | -| `IBankAccountOwnershipVerifier` | backend-phase-3/6 | استعلام شبا 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 | 🔴 | | `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoice — leaves ref pending | _tbd_ | Real مودیان submission → 22-digit ref | 🔴 | | `IReviewModerationService` | backend-phase-14 | AI moderation — keyword/pass-through | _tbd_ | Real classifier/LLM endpoint | 🔴 | @@ -36,3 +36,13 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 > Exact config keys and file paths get filled in by the phase that builds each seam. Keep the > "Make it real →" column actionable enough that a developer can pick up any single row and ship it. + +## Frontend client-side mocks (not backend DI seams) + +These are in-browser mocks behind a `services/{domain}` interface, selected by a config flag. They exist so +the frontend can build before the backend phase merges, and swap to the real HTTP client in one line. + +| Seam (interface) | File | What it fakes | Config flag | Make it real → | Status | +| --- | --- | --- | --- | --- | --- | +| `PatientsApi` | `client/src/services/patients/apis/mockApi.ts` | In-memory patient list/create | `USE_PATIENTS_MOCK` (`services/patients/constants.ts`) | Publish `/patients` endpoints, set flag `false` | 🟡 | +| `AuthApi` | `client/src/services/auth/apis/mockApi.ts` (`authMockApi`) | Phone-OTP login offline: `requestOtp`→`{otpSent,resendAvailableInSeconds:120}`; `verifyOtp` accepts dev code **`123456`** and locks after 3 wrong tries (`otp_locked`); `getMe`/`selectRole`/`refresh` from a `MOCK_SCENARIO` toggle (`customer`/`nurse_unverified`/`no_role`) to exercise all router branches | `USE_AUTH_MOCK` (`services/auth/constants.ts`, default **false** — b2 is live) + `MOCK_SCENARIO` in `mockApi.ts` | The real `authClientApi` is already wired to the live b2 routes; set `USE_AUTH_MOCK = false` (already the default) — no hook/screen change | 🟢 real by default, 🟡 mock available | diff --git a/product/data-model/01-identity-and-access.html b/product/data-model/01-identity-and-access.html index 137aa4b..ea7619e 100644 --- a/product/data-model/01-identity-and-access.html +++ b/product/data-model/01-identity-and-access.html @@ -48,6 +48,8 @@

Relations: 1:1 → users, nurse_verifications; 1:N → nurse_service_variants, nurse_service_areas, nurse_bank_accounts, nurse_credentials, bookings, nurse_payouts, nurse_clawbacks; N:1 → partner_centers.

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.

+

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]

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.

customer_addresses [CORE]

@@ -62,6 +64,8 @@ ownership_vendor_refNVARCHAR(200) NULLNEW — 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.

+

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).

+
↑ Back to top diff --git a/product/data-model/01-identity-and-access.md b/product/data-model/01-identity-and-access.md index 6e209bb..572647f 100644 --- a/product/data-model/01-identity-and-access.md +++ b/product/data-model/01-identity-and-access.md @@ -45,6 +45,10 @@ Fields unchanged from baseline: `id`, `email` (enc, nullable), `phone` (enc, uni ### `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`. +> **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] **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. | 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). diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 8b2b93a..1d93437 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -81,12 +81,12 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies. ``` src/ ├── Core/ -│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, + 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.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 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/ │ ├── 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.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 ├── API/ │ ├── 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 (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` 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` in the Application assembly as `IValidator` 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 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 diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/CustomerProfilesController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/CustomerProfilesController.cs new file mode 100644 index 0000000..ba59c4b --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/CustomerProfilesController.cs @@ -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] + public async Task Upsert(UpsertCustomerProfileCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType] + public async Task Me(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetMyCustomerProfileQuery(), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/NurseBankAccountsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/NurseBankAccountsController.cs new file mode 100644 index 0000000..2c39a1c --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/NurseBankAccountsController.cs @@ -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] + public async Task Add(AddNurseBankAccountCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task SetPrimary(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new SetPrimaryBankAccountCommand(id), cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task List(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new ListNurseBankAccountsQuery(), cancellationToken)); + + // Rate-limited: re-runs the استعلام شبا vendor inquiry. + [HttpPost("[action]/{id}")] + [EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)] + [ProducesOkApiResponseType] + public async Task VerifyOwnership(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new TriggerBankAccountOwnershipInquiryCommand(id), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/NurseProfilesController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/NurseProfilesController.cs new file mode 100644 index 0000000..8df2257 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/NurseProfilesController.cs @@ -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] + public async Task Upsert(UpsertNurseProfileCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task SetAcceptingBookings(SetNurseAcceptingBookingsCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType] + public async Task Me(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetMyNurseProfileQuery(), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/PatientsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/PatientsController.cs new file mode 100644 index 0000000..eb4e46d --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/PatientsController.cs @@ -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] + public async Task Create(CreatePatientCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task List([FromQuery] ListPatientsQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + [HttpGet("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task Get(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetPatientQuery(id), cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task Update(long id, UpdatePatientCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { Id = id }, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task Archive(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new ArchivePatientCommand(id), cancellationToken)); +} diff --git a/server/src/Core/Baya.Application/Common/Mask.cs b/server/src/Core/Baya.Application/Common/Mask.cs new file mode 100644 index 0000000..0c1f8b0 --- /dev/null +++ b/server/src/Core/Baya.Application/Common/Mask.cs @@ -0,0 +1,12 @@ +namespace Baya.Application.Common; + +/// Wire-masking for sensitive-but-displayable identifiers. +public static class Mask +{ + private const string MaskPrefix = "••••"; + + /// Masks an IBAN to its last four characters (e.g. ••••3456) so lists never carry the + /// full value. Returns the input unchanged when it is null/empty or already ≤4 chars. + public static string IbanTail(string iban) + => string.IsNullOrEmpty(iban) || iban.Length <= 4 ? iban : MaskPrefix + iban[^4..]; +} diff --git a/server/src/Core/Baya.Application/Contracts/Common/IBankAccountOwnershipVerifier.cs b/server/src/Core/Baya.Application/Contracts/Common/IBankAccountOwnershipVerifier.cs new file mode 100644 index 0000000..a70c800 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Common/IBankAccountOwnershipVerifier.cs @@ -0,0 +1,23 @@ +#nullable enable +namespace Baya.Application.Contracts.Common; + +/// +/// 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. +/// +public interface IBankAccountOwnershipVerifier +{ + /// Runs the ownership inquiry for the given IBAN against the nurse's national id. + Task VerifyOwnershipAsync(string iban, string? nurseNationalId, CancellationToken cancellationToken = default); +} + +/// +/// Outcome of an inquiry. +/// +/// Whether the IBAN owner's national id matches the nurse's. +/// The account-holder name the bank returned (snapshot). +/// The vendor transaction id, kept for audit. +public readonly record struct OwnershipInquiryResult(bool MatchedNationalId, string AccountHolderFromBank, string VendorRef); diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/ICustomerProfileRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/ICustomerProfileRepository.cs new file mode 100644 index 0000000..c6558c4 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/ICustomerProfileRepository.cs @@ -0,0 +1,21 @@ +#nullable enable +using Baya.Application.Models.Identity; +using Baya.Domain.Entities.Identity; + +namespace Baya.Application.Contracts.Persistence; + +public interface ICustomerProfileRepository +{ + /// Tracked lookup of the customer's profile by owning user id (for upsert). + Task GetByUserIdAsync(int userId, CancellationToken cancellationToken); + + Task AddAsync(CustomerProfile profile, CancellationToken cancellationToken); + + /// No-tracking projection of the signed-in customer's profile (emergency contact decrypted, + /// full, for the owner). + Task GetMineAsync(int userId, CancellationToken cancellationToken); + + /// The customer's customer_profiles.id from their user id — the tenancy anchor for + /// patient operations. NULL when the user has no customer profile yet. + Task GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/INurseBankAccountRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/INurseBankAccountRepository.cs new file mode 100644 index 0000000..3afa7eb --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/INurseBankAccountRepository.cs @@ -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); + + /// Tracked, tenancy-scoped lookup — returns the account only if it belongs to + /// , else null. + Task GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken); + + /// Atomically makes the nurse's primary account and clears any + /// prior primary, in a single transaction (clear-then-set order) so the filtered + /// UNIQUE(nurse_id) WHERE is_primary = 1 index never trips. The account must already be owned + /// by the nurse — the caller verifies tenancy first. + Task SetPrimaryAsync(long nurseId, long accountId, CancellationToken cancellationToken); + + /// Whether any account already carries this deterministic IBAN hash — the clean duplicate + /// guard (the UNIQUE(iban_hash) index is the authoritative backstop). + Task IbanHashExistsAsync(string ibanHash, CancellationToken cancellationToken); + + /// Whether the nurse already has at least one account (decides whether a new one defaults to + /// primary). + Task HasAnyAsync(long nurseId, CancellationToken cancellationToken); + + /// No-tracking projection of the nurse's accounts with the IBAN masked (last 4 only). + Task> ListAsync(long nurseId, CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/INurseProfileRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/INurseProfileRepository.cs new file mode 100644 index 0000000..926e759 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/INurseProfileRepository.cs @@ -0,0 +1,25 @@ +#nullable enable +using Baya.Application.Models.Identity; +using Baya.Domain.Entities.Identity; + +namespace Baya.Application.Contracts.Persistence; + +public interface INurseProfileRepository +{ + /// Tracked lookup of the nurse's profile by owning user id (for upsert/toggle). + Task GetByUserIdAsync(int userId, CancellationToken cancellationToken); + + Task AddAsync(NurseProfile profile, CancellationToken cancellationToken); + + /// No-tracking projection of the signed-in nurse's profile, incl. read-only verified flag + /// and aggregates. + Task GetMineAsync(int userId, CancellationToken cancellationToken); + + /// The nurse's nurse_profiles.id from their user id — the tenancy anchor for + /// bank-account operations. NULL when the user has no nurse profile yet. + Task GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken); + + /// The nurse's nurse_profiles.id + decrypted national id — what the bank-account + /// ownership inquiry needs. NULL when the user has no nurse profile yet. + Task GetIdentityContextByUserIdAsync(int userId, CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IPatientRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IPatientRepository.cs new file mode 100644 index 0000000..f870d57 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IPatientRepository.cs @@ -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); + + /// Tracked, tenancy-scoped lookup — returns the patient only if it belongs to + /// , else null (so callers surface a not-found, never a cross-tenant + /// mutation). + Task GetOwnedAsync(long id, long customerId, CancellationToken cancellationToken); + + /// Paginated, no-tracking projection of the customer's own patients. + Task> ListAsync(long customerId, int page, int pageSize, CancellationToken cancellationToken); + + /// No-tracking, tenancy-scoped projection of a single owned patient; null if not owned. + Task GetOwnedProjectedAsync(long id, long customerId, CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs index 1572774..de0284d 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs @@ -5,6 +5,10 @@ public interface IUnitOfWork public IUserRefreshTokenRepository UserRefreshTokenRepository { get; } public IUserSessionRepository UserSessionRepository { get; } public IUserAccountRepository UserAccountRepository { get; } + public INurseProfileRepository NurseProfileRepository { get; } + public ICustomerProfileRepository CustomerProfileRepository { get; } + public IPatientRepository PatientRepository { get; } + public INurseBankAccountRepository NurseBankAccountRepository { get; } Task CommitAsync(); ValueTask RollBackAsync(); -} \ No newline at end of file +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/AddNurseBankAccount/AddNurseBankAccountCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/AddNurseBankAccount/AddNurseBankAccountCommand.Handler.cs new file mode 100644 index 0000000..2eafc51 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/AddNurseBankAccount/AddNurseBankAccountCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(AddNurseBankAccountCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Nurse) != true) + return OperationResult.ForbiddenResult("Only a nurse can add a payout account."); + + var iban = Sheba.Normalize(request.Iban); + if (iban is null) + return OperationResult.FailureResult(nameof(request.Iban), "A valid Iranian IBAN (شبا) is required."); + + var context = await unitOfWork.NurseProfileRepository.GetIdentityContextByUserIdAsync(userId, cancellationToken); + if (context is null) + return OperationResult.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.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.SuccessResult(new NurseBankAccountDto( + account.Id, + account.BankName, + Mask.IbanTail(iban), + account.IsPrimary, + account.IsVerified, + account.MatchedNationalId)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/AddNurseBankAccount/AddNurseBankAccountCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/AddNurseBankAccount/AddNurseBankAccountCommand.Validator.cs new file mode 100644 index 0000000..1ab78b6 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/AddNurseBankAccount/AddNurseBankAccountCommand.Validator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace Baya.Application.Features.Identity.Commands.AddNurseBankAccount; + +public sealed class AddNurseBankAccountCommandValidator : AbstractValidator +{ + 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."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/AddNurseBankAccount/AddNurseBankAccountCommand.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/AddNurseBankAccount/AddNurseBankAccountCommand.cs new file mode 100644 index 0000000..ded96d9 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/AddNurseBankAccount/AddNurseBankAccountCommand.cs @@ -0,0 +1,15 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.AddNurseBankAccount; + +/// +/// Adds a payout account for the signed-in nurse. The IBAN and account-holder name are encrypted at rest; +/// a deterministic iban_hash guards against duplicates and the استعلام شبا ownership inquiry runs +/// immediately. If the nurse has no other account this one becomes primary. +/// +public record AddNurseBankAccountCommand( + string BankName, + string AccountHolderName, + string Iban) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/ArchivePatient/ArchivePatientCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/ArchivePatient/ArchivePatientCommand.Handler.cs new file mode 100644 index 0000000..2f4a419 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/ArchivePatient/ArchivePatientCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(ArchivePatientCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Customer) != true) + return OperationResult.ForbiddenResult("Only a customer can manage patients."); + + var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (customerId is not { } cid) + return OperationResult.NotFoundResult("Patient not found."); + + var patient = await unitOfWork.PatientRepository.GetOwnedAsync(request.Id, cid, cancellationToken); + if (patient is null) + return OperationResult.NotFoundResult("Patient not found."); + + patient.IsActive = false; + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/ArchivePatient/ArchivePatientCommand.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/ArchivePatient/ArchivePatientCommand.cs new file mode 100644 index 0000000..6ea1422 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/ArchivePatient/ArchivePatientCommand.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.ArchivePatient; + +/// Soft-archives a patient (is_active = false) the signed-in customer owns — not a hard +/// delete, so the longitudinal care record (b14) is preserved. +public record ArchivePatientCommand(long Id) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/CreatePatient/CreatePatientCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/CreatePatient/CreatePatientCommand.Handler.cs new file mode 100644 index 0000000..487e0ea --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/CreatePatient/CreatePatientCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(CreatePatientCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Customer) != true) + return OperationResult.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.SuccessResult(new PatientDto( + patient.Id, + patient.DisplayName, + patient.FirstName, + patient.LastName, + patient.BirthDate, + patient.Gender, + patient.BloodType, + patient.InitialMedicalNotes, + patient.IsActive)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/CreatePatient/CreatePatientCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/CreatePatient/CreatePatientCommand.Validator.cs new file mode 100644 index 0000000..47ce234 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/CreatePatient/CreatePatientCommand.Validator.cs @@ -0,0 +1,21 @@ +using FluentValidation; + +namespace Baya.Application.Features.Identity.Commands.CreatePatient; + +public sealed class CreatePatientCommandValidator : AbstractValidator +{ + 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."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/CreatePatient/CreatePatientCommand.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/CreatePatient/CreatePatientCommand.cs new file mode 100644 index 0000000..41c898b --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/CreatePatient/CreatePatientCommand.cs @@ -0,0 +1,19 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.CreatePatient; + +/// +/// Registers a care recipient under the signed-in customer. The owning customer is derived from the +/// caller — never from the request body. Gender is required (same-gender matching signal) and +/// InitialMedicalNotes is encrypted at rest. +/// +public record CreatePatientCommand( + string DisplayName, + string FirstName, + string LastName, + DateOnly BirthDate, + string Gender, + string BloodType, + string InitialMedicalNotes) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/SetNurseAcceptingBookings/SetNurseAcceptingBookingsCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/SetNurseAcceptingBookings/SetNurseAcceptingBookingsCommand.Handler.cs new file mode 100644 index 0000000..55d6e8f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/SetNurseAcceptingBookings/SetNurseAcceptingBookingsCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(SetNurseAcceptingBookingsCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Nurse) != true) + return OperationResult.ForbiddenResult("Only a nurse can manage a nurse profile."); + + var profile = await unitOfWork.NurseProfileRepository.GetByUserIdAsync(userId, cancellationToken); + if (profile is null) + return OperationResult.NotFoundResult("No nurse profile exists yet. Create your profile first."); + + profile.SetAcceptingBookings(request.Accepting); + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/SetNurseAcceptingBookings/SetNurseAcceptingBookingsCommand.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/SetNurseAcceptingBookings/SetNurseAcceptingBookingsCommand.cs new file mode 100644 index 0000000..ce44808 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/SetNurseAcceptingBookings/SetNurseAcceptingBookingsCommand.cs @@ -0,0 +1,7 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings; + +/// Pauses or resumes the signed-in nurse's bookability without touching verified status. +public record SetNurseAcceptingBookingsCommand(bool Accepting) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/SetPrimaryBankAccount/SetPrimaryBankAccountCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/SetPrimaryBankAccount/SetPrimaryBankAccountCommand.Handler.cs new file mode 100644 index 0000000..15def94 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/SetPrimaryBankAccount/SetPrimaryBankAccountCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(SetPrimaryBankAccountCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Nurse) != true) + return OperationResult.ForbiddenResult("Only a nurse can manage payout accounts."); + + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is not { } nid) + return OperationResult.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.NotFoundResult("Bank account not found."); + + if (!account.IsPrimary) + await unitOfWork.NurseBankAccountRepository.SetPrimaryAsync(nid, request.Id, cancellationToken); + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/SetPrimaryBankAccount/SetPrimaryBankAccountCommand.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/SetPrimaryBankAccount/SetPrimaryBankAccountCommand.cs new file mode 100644 index 0000000..d5b16fd --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/SetPrimaryBankAccount/SetPrimaryBankAccountCommand.cs @@ -0,0 +1,7 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.SetPrimaryBankAccount; + +/// Makes one of the signed-in nurse's accounts primary, clearing the prior primary atomically. +public record SetPrimaryBankAccountCommand(long Id) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/TriggerBankAccountOwnershipInquiry/TriggerBankAccountOwnershipInquiryCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/TriggerBankAccountOwnershipInquiry/TriggerBankAccountOwnershipInquiryCommand.Handler.cs new file mode 100644 index 0000000..ef0ff82 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/TriggerBankAccountOwnershipInquiry/TriggerBankAccountOwnershipInquiryCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(TriggerBankAccountOwnershipInquiryCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Nurse) != true) + return OperationResult.ForbiddenResult("Only a nurse can run an ownership inquiry."); + + var context = await unitOfWork.NurseProfileRepository.GetIdentityContextByUserIdAsync(userId, cancellationToken); + if (context is null) + return OperationResult.NotFoundResult("Bank account not found."); + + var account = await unitOfWork.NurseBankAccountRepository.GetOwnedAsync(request.Id, context.NurseProfileId, cancellationToken); + if (account is null) + return OperationResult.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.SuccessResult(new NurseBankAccountDto( + account.Id, + account.BankName, + Mask.IbanTail(account.Iban), + account.IsPrimary, + account.IsVerified, + account.MatchedNationalId)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/TriggerBankAccountOwnershipInquiry/TriggerBankAccountOwnershipInquiryCommand.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/TriggerBankAccountOwnershipInquiry/TriggerBankAccountOwnershipInquiryCommand.cs new file mode 100644 index 0000000..3eb498b --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/TriggerBankAccountOwnershipInquiry/TriggerBankAccountOwnershipInquiryCommand.cs @@ -0,0 +1,12 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.TriggerBankAccountOwnershipInquiry; + +/// +/// Re-runs the استعلام شبا ownership inquiry for an existing account (e.g. after a NULL/failed first +/// attempt) and updates matched_national_id/account_holder_from_bank/ +/// ownership_vendor_ref. Idempotent — the same input yields the same vendor ref from the mock. +/// +public record TriggerBankAccountOwnershipInquiryCommand(long Id) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/UpdatePatient/UpdatePatientCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/UpdatePatient/UpdatePatientCommand.Handler.cs new file mode 100644 index 0000000..e3c569f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/UpdatePatient/UpdatePatientCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(UpdatePatientCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Customer) != true) + return OperationResult.ForbiddenResult("Only a customer can manage patients."); + + var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (customerId is not { } cid) + return OperationResult.NotFoundResult("Patient not found."); + + var patient = await unitOfWork.PatientRepository.GetOwnedAsync(request.Id, cid, cancellationToken); + if (patient is null) + return OperationResult.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.SuccessResult(new PatientDto( + patient.Id, + patient.DisplayName, + patient.FirstName, + patient.LastName, + patient.BirthDate, + patient.Gender, + patient.BloodType, + patient.InitialMedicalNotes, + patient.IsActive)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/UpdatePatient/UpdatePatientCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/UpdatePatient/UpdatePatientCommand.Validator.cs new file mode 100644 index 0000000..cc15bcc --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/UpdatePatient/UpdatePatientCommand.Validator.cs @@ -0,0 +1,22 @@ +using FluentValidation; + +namespace Baya.Application.Features.Identity.Commands.UpdatePatient; + +public sealed class UpdatePatientCommandValidator : AbstractValidator +{ + 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."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/UpdatePatient/UpdatePatientCommand.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/UpdatePatient/UpdatePatientCommand.cs new file mode 100644 index 0000000..8aaaef3 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/UpdatePatient/UpdatePatientCommand.cs @@ -0,0 +1,16 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.UpdatePatient; + +/// Updates a patient the signed-in customer owns; tenancy-checked, re-encrypts changed PII. +public record UpdatePatientCommand( + long Id, + string DisplayName, + string FirstName, + string LastName, + DateOnly BirthDate, + string Gender, + string BloodType, + string InitialMedicalNotes) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertCustomerProfile/UpsertCustomerProfileCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertCustomerProfile/UpsertCustomerProfileCommand.Handler.cs new file mode 100644 index 0000000..f79d712 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertCustomerProfile/UpsertCustomerProfileCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(UpsertCustomerProfileCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Customer) != true) + return OperationResult.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.SuccessResult(dto!); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertCustomerProfile/UpsertCustomerProfileCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertCustomerProfile/UpsertCustomerProfileCommand.Validator.cs new file mode 100644 index 0000000..0e97a6e --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertCustomerProfile/UpsertCustomerProfileCommand.Validator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace Baya.Application.Features.Identity.Commands.UpsertCustomerProfile; + +public sealed class UpsertCustomerProfileCommandValidator : AbstractValidator +{ + 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)."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertCustomerProfile/UpsertCustomerProfileCommand.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertCustomerProfile/UpsertCustomerProfileCommand.cs new file mode 100644 index 0000000..15783a8 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertCustomerProfile/UpsertCustomerProfileCommand.cs @@ -0,0 +1,13 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.UpsertCustomerProfile; + +/// +/// 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. +/// +public record UpsertCustomerProfileCommand( + string DefaultEmergencyContactName, + string DefaultEmergencyContactPhone) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertNurseProfile/UpsertNurseProfileCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertNurseProfile/UpsertNurseProfileCommand.Handler.cs new file mode 100644 index 0000000..de0614a --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertNurseProfile/UpsertNurseProfileCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(UpsertNurseProfileCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Nurse) != true) + return OperationResult.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.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; + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertNurseProfile/UpsertNurseProfileCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertNurseProfile/UpsertNurseProfileCommand.Validator.cs new file mode 100644 index 0000000..ad5bec4 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertNurseProfile/UpsertNurseProfileCommand.Validator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Baya.Application.Features.Identity.Commands.UpsertNurseProfile; + +public sealed class UpsertNurseProfileCommandValidator : AbstractValidator +{ + 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); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertNurseProfile/UpsertNurseProfileCommand.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertNurseProfile/UpsertNurseProfileCommand.cs new file mode 100644 index 0000000..988d005 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/UpsertNurseProfile/UpsertNurseProfileCommand.cs @@ -0,0 +1,16 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Commands.UpsertNurseProfile; + +/// +/// Creates (first call) or updates the signed-in nurse's seller profile. Never accepts the guarded +/// is_verified flag or the read-only aggregates. Idempotent on the owning user. +/// +public record UpsertNurseProfileCommand( + string Bio, + int YearsOfExperience, + string EducationLevel, + string EducationField, + string SpecializationsJson) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Identity/PatientRules.cs b/server/src/Core/Baya.Application/Features/Identity/PatientRules.cs new file mode 100644 index 0000000..04685f1 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/PatientRules.cs @@ -0,0 +1,9 @@ +namespace Baya.Application.Features.Identity; + +/// Shared patient input rules used by the create/update validators. +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); +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Queries/GetMyCustomerProfile/GetMyCustomerProfileQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Queries/GetMyCustomerProfile/GetMyCustomerProfileQuery.Handler.cs new file mode 100644 index 0000000..6e4567d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Queries/GetMyCustomerProfile/GetMyCustomerProfileQuery.Handler.cs @@ -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> +{ + public async ValueTask> Handle(GetMyCustomerProfileQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var dto = await unitOfWork.CustomerProfileRepository.GetMineAsync(userId, cancellationToken); + return dto is null + ? OperationResult.NotFoundResult("No customer profile exists yet.") + : OperationResult.SuccessResult(dto); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Queries/GetMyCustomerProfile/GetMyCustomerProfileQuery.cs b/server/src/Core/Baya.Application/Features/Identity/Queries/GetMyCustomerProfile/GetMyCustomerProfileQuery.cs new file mode 100644 index 0000000..a2e6a70 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Queries/GetMyCustomerProfile/GetMyCustomerProfileQuery.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Queries.GetMyCustomerProfile; + +/// Projects the signed-in customer's profile (emergency contact returned in full to the owner). +public record GetMyCustomerProfileQuery : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Identity/Queries/GetMyNurseProfile/GetMyNurseProfileQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Queries/GetMyNurseProfile/GetMyNurseProfileQuery.Handler.cs new file mode 100644 index 0000000..ccde7cc --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Queries/GetMyNurseProfile/GetMyNurseProfileQuery.Handler.cs @@ -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> +{ + public async ValueTask> Handle(GetMyNurseProfileQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var dto = await unitOfWork.NurseProfileRepository.GetMineAsync(userId, cancellationToken); + return dto is null + ? OperationResult.NotFoundResult("No nurse profile exists yet.") + : OperationResult.SuccessResult(dto); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Queries/GetMyNurseProfile/GetMyNurseProfileQuery.cs b/server/src/Core/Baya.Application/Features/Identity/Queries/GetMyNurseProfile/GetMyNurseProfileQuery.cs new file mode 100644 index 0000000..3d42cb7 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Queries/GetMyNurseProfile/GetMyNurseProfileQuery.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Queries.GetMyNurseProfile; + +/// Projects the signed-in nurse's profile, incl. read-only verified flag and aggregates. +public record GetMyNurseProfileQuery : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Identity/Queries/GetPatient/GetPatientQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Queries/GetPatient/GetPatientQuery.Handler.cs new file mode 100644 index 0000000..996b25e --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Queries/GetPatient/GetPatientQuery.Handler.cs @@ -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> +{ + public async ValueTask> Handle(GetPatientQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.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.NotFoundResult("Patient not found."); + + var dto = await unitOfWork.PatientRepository.GetOwnedProjectedAsync(request.Id, cid, cancellationToken); + return dto is null + ? OperationResult.NotFoundResult("Patient not found.") + : OperationResult.SuccessResult(dto); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Queries/GetPatient/GetPatientQuery.cs b/server/src/Core/Baya.Application/Features/Identity/Queries/GetPatient/GetPatientQuery.cs new file mode 100644 index 0000000..cb6c842 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Queries/GetPatient/GetPatientQuery.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Queries.GetPatient; + +/// Returns one patient only if it belongs to the signed-in customer, else not-found. +public record GetPatientQuery(long Id) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Identity/Queries/ListNurseBankAccounts/ListNurseBankAccountsQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Queries/ListNurseBankAccounts/ListNurseBankAccountsQuery.Handler.cs new file mode 100644 index 0000000..1335872 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Queries/ListNurseBankAccounts/ListNurseBankAccountsQuery.Handler.cs @@ -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>> +{ + public async ValueTask>> Handle(ListNurseBankAccountsQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult>.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Nurse) != true) + return OperationResult>.ForbiddenResult("Only a nurse can view payout accounts."); + + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is not { } nid) + return OperationResult>.SuccessResult([]); + + var accounts = await unitOfWork.NurseBankAccountRepository.ListAsync(nid, cancellationToken); + return OperationResult>.SuccessResult(accounts); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Queries/ListNurseBankAccounts/ListNurseBankAccountsQuery.cs b/server/src/Core/Baya.Application/Features/Identity/Queries/ListNurseBankAccounts/ListNurseBankAccountsQuery.cs new file mode 100644 index 0000000..47b568f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Queries/ListNurseBankAccounts/ListNurseBankAccountsQuery.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Queries.ListNurseBankAccounts; + +/// Lists the signed-in nurse's payout accounts with the IBAN masked (last 4 only). +public record ListNurseBankAccountsQuery : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Identity/Queries/ListPatients/ListPatientsQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Queries/ListPatients/ListPatientsQuery.Handler.cs new file mode 100644 index 0000000..0bf3b52 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Queries/ListPatients/ListPatientsQuery.Handler.cs @@ -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>> +{ + public async ValueTask>> Handle(ListPatientsQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult>.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>.SuccessResult(new PagedResult([], 0, page, pageSize)); + + var result = await unitOfWork.PatientRepository.ListAsync(cid, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Queries/ListPatients/ListPatientsQuery.cs b/server/src/Core/Baya.Application/Features/Identity/Queries/ListPatients/ListPatientsQuery.cs new file mode 100644 index 0000000..a14444c --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Queries/ListPatients/ListPatientsQuery.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Identity; +using Mediator; + +namespace Baya.Application.Features.Identity.Queries.ListPatients; + +/// Lists the signed-in customer's own patients only (tenancy-scoped, paginated). +public record ListPatientsQuery(int Page = 1, int PageSize = 50) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Identity/Sheba.cs b/server/src/Core/Baya.Application/Features/Identity/Sheba.cs new file mode 100644 index 0000000..1e315d3 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Identity/Sheba.cs @@ -0,0 +1,27 @@ +#nullable enable +using System.Text.RegularExpressions; +using Baya.SharedKernel.Extensions; + +namespace Baya.Application.Features.Identity; + +/// +/// Normalizes and validates an Iranian IBAN (شبا): the canonical IR + 24 digits, uppercase, no +/// spaces (Persian digits translated). The canonical form is what gets stored, encrypted, and hashed — +/// so the deterministic iban_hash uniqueness holds regardless of how the nurse typed it. +/// +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; +} diff --git a/server/src/Core/Baya.Application/Models/Identity/CustomerProfileDto.cs b/server/src/Core/Baya.Application/Models/Identity/CustomerProfileDto.cs new file mode 100644 index 0000000..975c683 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Identity/CustomerProfileDto.cs @@ -0,0 +1,10 @@ +namespace Baya.Application.Models.Identity; + +/// +/// 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). +/// +public record CustomerProfileDto( + long Id, + string DefaultEmergencyContactName, + string DefaultEmergencyContactPhone); diff --git a/server/src/Core/Baya.Application/Models/Identity/NurseBankAccountDto.cs b/server/src/Core/Baya.Application/Models/Identity/NurseBankAccountDto.cs new file mode 100644 index 0000000..e32771a --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Identity/NurseBankAccountDto.cs @@ -0,0 +1,13 @@ +namespace Baya.Application.Models.Identity; + +/// +/// A nurse payout account. The IBAN is returned masked (last 4 only) — the full value is never +/// sent on the wire. MatchedNationalId is NULL until the استعلام شبا ownership inquiry has run. +/// +public record NurseBankAccountDto( + long Id, + string BankName, + string IbanMasked, + bool IsPrimary, + bool IsVerified, + bool? MatchedNationalId); diff --git a/server/src/Core/Baya.Application/Models/Identity/NurseIdentityContext.cs b/server/src/Core/Baya.Application/Models/Identity/NurseIdentityContext.cs new file mode 100644 index 0000000..a813c5e --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Identity/NurseIdentityContext.cs @@ -0,0 +1,10 @@ +namespace Baya.Application.Models.Identity; + +/// +/// The minimal nurse facts a bank-account command needs: the owning nurse_profiles id and the +/// nurse's national id (decrypted) for the استعلام شبا ownership inquiry. National id is NULL until the +/// b6 KYC pipeline populates it. +/// +/// The signed-in nurse's nurse_profiles.id. +/// The nurse's national id, decrypted; NULL before KYC. +public record NurseIdentityContext(long NurseProfileId, string NationalId); diff --git a/server/src/Core/Baya.Application/Models/Identity/NurseProfileDto.cs b/server/src/Core/Baya.Application/Models/Identity/NurseProfileDto.cs new file mode 100644 index 0000000..a172d7a --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Identity/NurseProfileDto.cs @@ -0,0 +1,18 @@ +namespace Baya.Application.Models.Identity; + +/// +/// The signed-in nurse's seller profile. IsVerified and the aggregates are read-only — they are +/// never set through a profile command in this phase. +/// +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); diff --git a/server/src/Core/Baya.Application/Models/Identity/PatientDto.cs b/server/src/Core/Baya.Application/Models/Identity/PatientDto.cs new file mode 100644 index 0000000..f3dac0f --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Identity/PatientDto.cs @@ -0,0 +1,16 @@ +namespace Baya.Application.Models.Identity; + +/// +/// A care recipient owned by the signed-in customer. InitialMedicalNotes is decrypted and +/// returned only to the owning customer. +/// +public record PatientDto( + long Id, + string DisplayName, + string FirstName, + string LastName, + DateOnly BirthDate, + string Gender, + string BloodType, + string InitialMedicalNotes, + bool IsActive); diff --git a/server/src/Core/Baya.Application/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Core/Baya.Application/ServiceConfiguration/ServiceCollectionExtension.cs index 3edd090..de5040c 100644 --- a/server/src/Core/Baya.Application/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Core/Baya.Application/ServiceConfiguration/ServiceCollectionExtension.cs @@ -1,6 +1,5 @@ -using System.Reflection; -using Baya.Application.Common; -using Mapster; +using Baya.Application.Common; +using FluentValidation; using Mediator; using Microsoft.Extensions.DependencyInjection; @@ -21,11 +20,30 @@ public static class ServiceCollectionExtension services.AddScoped(typeof(IPipelineBehavior<,>), typeof(MetricsBehaviour<,>)); services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidateCommandBehavior<,>)); - + RegisterCommandValidators(services); return services; } - + // Registers every FluentValidation AbstractValidator in this assembly as IValidator 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); + } + } } \ No newline at end of file diff --git a/server/src/Core/Baya.Domain/Entities/Identity/CustomerProfile.cs b/server/src/Core/Baya.Domain/Entities/Identity/CustomerProfile.cs new file mode 100644 index 0000000..64d6edb --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Identity/CustomerProfile.cs @@ -0,0 +1,22 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Identity; + +/// +/// The thin payer extension for a customer. Intentionally lightweight — most customer reality lives in +/// their patients, addresses and bookings. Customer national-ID KYC is deferred at launch, so no +/// verification columns exist here. +/// +public class CustomerProfile : BaseEntity +{ + public int UserId { get; set; } + public User.User User { get; set; } + + /// Encrypted at rest. + public string DefaultEmergencyContactName { get; set; } + + /// Encrypted at rest. + public string DefaultEmergencyContactPhone { get; set; } + + public ICollection Patients { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Identity/NurseBankAccount.cs b/server/src/Core/Baya.Domain/Entities/Identity/NurseBankAccount.cs new file mode 100644 index 0000000..6db2a2e --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Identity/NurseBankAccount.cs @@ -0,0 +1,53 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Identity; + +/// +/// 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 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 +/// () — the first-payout gate (b13), not an admin's eyeballs. +/// +public class NurseBankAccount : BaseEntity +{ + public long NurseId { get; set; } + public NurseProfile Nurse { get; set; } + + public string BankName { get; set; } + + /// Encrypted at rest. + public string AccountHolderName { get; set; } + + /// Encrypted at rest. Non-deterministic ciphertext — never equality-queried; lookups go + /// through . + public string Iban { get; set; } + + /// Deterministic keyed hash of the normalized IBAN (via IFieldEncryptor.Hash) — + /// carries the UNIQUE index, since the ciphertext itself can't be uniquely indexed. + public string IbanHash { get; set; } + + public bool IsPrimary { get; set; } + + /// Result of the استعلام شبا IBAN-owner ↔ national-id inquiry. NULL until the inquiry runs; + /// the first payout (b13) is gated on true. + public bool? MatchedNationalId { get; set; } + + /// The account-holder name the bank returned by the ownership inquiry — a snapshot. + public string AccountHolderFromBank { get; set; } + + /// The ownership-inquiry vendor transaction id, kept for audit. + public string OwnershipVendorRef { get; set; } + + public bool IsVerified { get; set; } + public int? VerifiedByAdminId { get; set; } + public DateTimeOffset? VerifiedAt { get; set; } + + /// Records the outcome of an استعلام شبا ownership inquiry against this account. + public void ApplyOwnershipInquiry(bool matchedNationalId, string accountHolderFromBank, string vendorRef) + { + MatchedNationalId = matchedNationalId; + AccountHolderFromBank = accountHolderFromBank; + OwnershipVendorRef = vendorRef; + } +} diff --git a/server/src/Core/Baya.Domain/Entities/Identity/NurseProfile.cs b/server/src/Core/Baya.Domain/Entities/Identity/NurseProfile.cs new file mode 100644 index 0000000..015c9ef --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Identity/NurseProfile.cs @@ -0,0 +1,54 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Identity; + +/// +/// A nurse's seller profile plus the denormalized search/quality aggregates. Separated from +/// users 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 . +/// +public class NurseProfile : BaseEntity +{ + public int UserId { get; set; } + public User.User User { get; set; } + + /// The licensed center legally sponsoring this nurse at launch (Asanism model). NULL once + /// Balinyaar holds its own permit. Forward dependency on partner_centers (b15) — nullable, + /// no FK target is enforced in this phase. + 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; } + + /// 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. + public bool IsVerified { get; private set; } + + /// Whether the nurse currently accepts new bookings. A nurse can pause without losing + /// verified status — toggled via . + public bool IsAcceptingBookings { get; private set; } + + /// Denormalized read-only aggregate. Defaults to 0; recomputed by the reviews/bookings + /// phases (b9/b14) — never accepted from a request in this phase. + 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 BankAccounts { get; set; } + + /// The single sanctioned write path for the guarded verified flag — called only by the b6 + /// verification-confirm transaction. + public void MarkVerified() => IsVerified = true; + + public void MarkUnverified() => IsVerified = false; + + public void SetAcceptingBookings(bool accepting) => IsAcceptingBookings = accepting; +} diff --git a/server/src/Core/Baya.Domain/Entities/Identity/Patient.cs b/server/src/Core/Baya.Domain/Entities/Identity/Patient.cs new file mode 100644 index 0000000..10a9818 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Identity/Patient.cs @@ -0,0 +1,32 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Identity; + +/// +/// 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 . +/// +public class Patient : BaseEntity +{ + 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; } + + /// "male" / "female". Required — load-bearing for same-gender caregiver matching. + public string Gender { get; set; } + + public string BloodType { get; set; } + + /// Encrypted at rest. + public string InitialMedicalNotes { get; set; } + + /// Archive flag — a patient is soft-archived (not hard-deleted) so the longitudinal care + /// record (b14) is preserved. + public bool IsActive { get; set; } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBankAccountOwnershipVerifier.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBankAccountOwnershipVerifier.cs new file mode 100644 index 0000000..4bd99cb --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBankAccountOwnershipVerifier.cs @@ -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; + +/// +/// Mock : a deterministic fake استعلام شبا inquiry — no real +/// bank/KYC call and no money moves. Every IBAN returns a match except the configured +/// , which returns MatchedNationalId = false 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. +/// +public sealed class MockBankAccountOwnershipVerifier(IOptions 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 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]; +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs index 6a6d7cf..d612d95 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs @@ -10,6 +10,24 @@ public sealed class SeamOptions public FieldEncryptionOptions FieldEncryption { get; set; } = new(); public ObjectStorageOptions ObjectStorage { get; set; } = new(); + public BankOwnershipOptions BankOwnership { get; set; } = new(); +} + +/// +/// Tunes the mock IBankAccountOwnershipVerifier (استعلام شبا). A submitted IBAN equal to +/// returns an ownership mismatch so the payout-gating path is testable; every +/// other IBAN returns a match. The real vendor implementation ignores these. +/// +public sealed class BankOwnershipOptions +{ + /// The designated test IBAN that returns matched_national_id = false. + public string MismatchIban { get; set; } = "IR000000000000000000000000"; + + /// The account-holder name the mock echoes back for a matching inquiry. + public string MatchedHolderName { get; set; } = "Verified Account Holder"; + + /// The account-holder name the mock returns for the mismatch IBAN. + public string MismatchHolderName { get; set; } = "Unmatched Account Holder"; } public sealed class FieldEncryptionOptions diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs index c0777fc..58522a0 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs @@ -28,6 +28,10 @@ public static class ServiceCollectionExtension // (Kavenegar/Ghasedak/SMS.ir) replaces this registration only. services.AddSingleton(); + // استعلام شبا 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(); + return services; } } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs index 6d83609..c73e48e 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs @@ -1,6 +1,7 @@ using System.Reflection; using Baya.Application.Contracts.Common; using Baya.Domain.Common; +using Baya.Domain.Entities.Identity; using Baya.Domain.Entities.User; using Baya.Infrastructure.Persistence.ValueConversion; using Baya.SharedKernel.Extensions; @@ -103,5 +104,22 @@ public class ApplicationDbContext: IdentityDbContext u.NormalizedEmail).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(builder => + { + builder.Property(c => c.DefaultEmergencyContactName).HasConversion(encrypted); + builder.Property(c => c.DefaultEmergencyContactPhone).HasConversion(encrypted); + }); + modelBuilder.Entity(builder => + { + builder.Property(p => p.InitialMedicalNotes).HasConversion(encrypted); + }); + modelBuilder.Entity(builder => + { + builder.Property(a => a.AccountHolderName).HasConversion(encrypted); + builder.Property(a => a.Iban).HasConversion(encrypted); + }); } } \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/CustomerProfileConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/CustomerProfileConfig.cs new file mode 100644 index 0000000..c1a7047 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/CustomerProfileConfig.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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(c => c.UserId) + .IsRequired(); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/NurseBankAccountConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/NurseBankAccountConfig.cs new file mode 100644 index 0000000..6d2bb59 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/NurseBankAccountConfig.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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() + .WithMany() + .HasForeignKey(a => a.VerifiedByAdminId) + .IsRequired(false); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/NurseProfileConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/NurseProfileConfig.cs new file mode 100644 index 0000000..8ed05be --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/NurseProfileConfig.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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(p => p.UserId) + .IsRequired(); + + builder.HasQueryFilter(p => p.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/PatientConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/PatientConfig.cs new file mode 100644 index 0000000..01e95ae --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/PatientConfig.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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(); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702042131_IdentityProfilesPatientsBankAccounts.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702042131_IdentityProfilesPatientsBankAccounts.Designer.cs new file mode 100644 index 0000000..e017dda --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702042131_IdentityProfilesPatientsBankAccounts.Designer.cs @@ -0,0 +1,1333 @@ +// +using System; +using Baya.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Baya.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260702042131_IdentityProfilesPatientsBankAccounts")] + partial class IdentityProfilesPatientsBankAccounts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("PropsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("UserId"); + + b.ToTable("SystemEvents", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ActorUserId") + .HasColumnType("int"); + + b.Property("ChangedFieldsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogs", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("PlatformConfigs", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "Balinyaar commission rate on the booking gross (fraction).", + Key = "platform_fee_rate", + Value = "0.15" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "VAT rate applied to the commission line only (fraction).", + Key = "vat_rate", + Value = "0.10" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours after check-out a booking can be disputed.", + Key = "dispute_window_hours", + Value = "72" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Minutes a family has to pay before a pending booking expires.", + Key = "booking_payment_deadline_minutes", + Value = "30" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours a nurse has to accept/decline a booking request.", + Key = "nurse_response_deadline_hours", + Value = "24" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Weekly payout cadence in days.", + Key = "nurse_payout_interval_days", + Value = "7" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Allowed EVV check-in distance from the care address.", + Key = "evv_location_tolerance_meters", + Value = "200" + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "A review at or below this rating raises a support alert.", + Key = "min_rating_for_support_alert", + Value = "2" + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "Who is merchant of record for BNPL orders (platform|nurse).", + Key = "bnpl_merchant_of_record", + Value = "platform" + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "BNPL provider commission rate (fraction).", + Key = "bnpl_provider_commission_rate", + Value = "0.07" + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "When BNPL settles funds to the platform (immediate|deferred).", + Key = "bnpl_settlement_timing", + Value = "immediate" + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "json", + Description = "Tiered cancellation refund policy: refund_percent by hours before the visit.", + Key = "cancellation_tiers", + Value = "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]" + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Seconds a phone must wait before another OTP can be requested.", + Key = "auth_otp_resend_seconds", + Value = "120" + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Wrong-code attempts allowed before OTP verification is refused until a fresh code.", + Key = "auth_otp_max_attempts", + Value = "5" + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Refresh-token session lifetime in days.", + Key = "auth_session_ttl_days", + Value = "30" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Holidays.IranianHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("HolidayDate") + .HasColumnType("date"); + + b.Property("IsBankClosed") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("HolidayDate") + .IsUnique(); + + b.ToTable("IranianHolidays", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 2, 11), + IsBankClosed = true, + NameFa = "پیروزی انقلاب اسلامی", + Type = "national" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 21), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 22), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 23), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 24), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 4, 1), + IsBankClosed = true, + NameFa = "روز طبیعت (سیزده‌به‌در)", + Type = "official" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 6, 26), + IsBankClosed = true, + NameFa = "عید سعید قربان", + Type = "religious" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DefaultEmergencyContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultEmergencyContactPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("CustomerProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountHolderFromBank") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("AccountHolderName") + .HasColumnType("nvarchar(max)"); + + b.Property("BankName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Iban") + .HasColumnType("nvarchar(max)"); + + b.Property("IbanHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("MatchedNationalId") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OwnershipVendorRef") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("VerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AverageRating") + .ValueGeneratedOnAdd() + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)") + .HasDefaultValue(0m); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EducationField") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("EducationLevel") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsAcceptingBookings") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("SpecializationsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("TotalCompletedBookings") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("TotalReviews") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("YearsOfExperience") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NurseProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("FirstName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("InitialMedicalNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Patients", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ReadAt") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRead", "CreatedAt"); + + b.ToTable("Notifications", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OwnerUserId") + .HasColumnType("int"); + + b.Property("ResolutionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewId") + .HasColumnType("bigint"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.ToTable("SupportAlerts", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedClaim") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("UserId"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FamilyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("GeneratedCode") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalId") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("PhoneVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("ShahkarVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.HasIndex("PhoneHash") + .IsUnique() + .HasFilter("[PhoneHash] IS NOT NULL"); + + b.ToTable("Users", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("LoggedOn") + .HasColumnType("datetime2"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("IsValid") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRefreshTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("CreatedUserRoleDate") + .HasColumnType("datetime2"); + + b.Property("GrantedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GrantedById") + .HasColumnType("int"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("GrantedById"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeviceInfo") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsRevoked") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RefreshTokenHash") + .IsUnique(); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("GeneratedTime") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .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 => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("OwnerUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("GrantedById"); + + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + 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 => + { + b.Navigation("Claims"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("Sessions"); + + b.Navigation("Tokens"); + + b.Navigation("UserRefreshTokens"); + + b.Navigation("UserRoles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702042131_IdentityProfilesPatientsBankAccounts.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702042131_IdentityProfilesPatientsBankAccounts.cs new file mode 100644 index 0000000..d1e2c2b --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702042131_IdentityProfilesPatientsBankAccounts.cs @@ -0,0 +1,215 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Baya.Infrastructure.Persistence.Migrations +{ + /// + public partial class IdentityProfilesPatientsBankAccounts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CustomerProfiles", + schema: "usr", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "int", nullable: false), + DefaultEmergencyContactName = table.Column(type: "nvarchar(max)", nullable: true), + DefaultEmergencyContactPhone = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(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(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "int", nullable: false), + PartnerCenterId = table.Column(type: "bigint", nullable: true), + Bio = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: true), + YearsOfExperience = table.Column(type: "int", nullable: false), + EducationLevel = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + EducationField = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: true), + SpecializationsJson = table.Column(type: "nvarchar(max)", nullable: true), + IsVerified = table.Column(type: "bit", nullable: false, defaultValue: false), + IsAcceptingBookings = table.Column(type: "bit", nullable: false, defaultValue: false), + AverageRating = table.Column(type: "decimal(3,2)", precision: 3, scale: 2, nullable: false, defaultValue: 0m), + TotalReviews = table.Column(type: "int", nullable: false, defaultValue: 0), + TotalCompletedBookings = table.Column(type: "int", nullable: false, defaultValue: 0), + DeletedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(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(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CustomerId = table.Column(type: "bigint", nullable: false), + DisplayName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + FirstName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + LastName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + BirthDate = table.Column(type: "date", nullable: false), + Gender = table.Column(type: "nvarchar(10)", maxLength: 10, nullable: false), + BloodType = table.Column(type: "nvarchar(10)", maxLength: 10, nullable: true), + InitialMedicalNotes = table.Column(type: "nvarchar(max)", nullable: true), + IsActive = table.Column(type: "bit", nullable: false, defaultValue: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(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(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + NurseId = table.Column(type: "bigint", nullable: false), + BankName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + AccountHolderName = table.Column(type: "nvarchar(max)", nullable: true), + Iban = table.Column(type: "nvarchar(max)", nullable: true), + IbanHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + IsPrimary = table.Column(type: "bit", nullable: false, defaultValue: false), + MatchedNationalId = table.Column(type: "bit", nullable: true), + AccountHolderFromBank = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + OwnershipVendorRef = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + IsVerified = table.Column(type: "bit", nullable: false, defaultValue: false), + VerifiedByAdminId = table.Column(type: "int", nullable: true), + VerifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(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"); + } + + /// + 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"); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 3da177a..cde6304 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -390,6 +390,266 @@ namespace Baya.Infrastructure.Persistence.Migrations }); }); + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DefaultEmergencyContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultEmergencyContactPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("CustomerProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountHolderFromBank") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("AccountHolderName") + .HasColumnType("nvarchar(max)"); + + b.Property("BankName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Iban") + .HasColumnType("nvarchar(max)"); + + b.Property("IbanHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("MatchedNationalId") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OwnershipVendorRef") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("VerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AverageRating") + .ValueGeneratedOnAdd() + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)") + .HasDefaultValue(0m); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EducationField") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("EducationLevel") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsAcceptingBookings") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("SpecializationsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("TotalCompletedBookings") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("TotalReviews") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("YearsOfExperience") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NurseProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("FirstName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("InitialMedicalNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Patients", "usr"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => { b.Property("Id") @@ -880,6 +1140,54 @@ namespace Baya.Infrastructure.Persistence.Migrations .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 => { b.HasOne("Baya.Domain.Entities.User.User", null) @@ -985,6 +1293,16 @@ namespace Baya.Infrastructure.Persistence.Migrations 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 => { b.Navigation("Claims"); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs index ed25107..243e1ba 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs @@ -9,6 +9,10 @@ public class UnitOfWork : IUnitOfWork public IUserRefreshTokenRepository UserRefreshTokenRepository { get; } public IUserSessionRepository UserSessionRepository { 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) { @@ -16,9 +20,13 @@ public class UnitOfWork : IUnitOfWork UserRefreshTokenRepository = new UserRefreshTokenRepository(_db); UserSessionRepository = new UserSessionRepository(_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() { return _db.SaveChangesAsync(); } @@ -28,4 +36,4 @@ public class UnitOfWork : IUnitOfWork _db.ChangeTracker.Clear(); return ValueTask.CompletedTask; } -} \ No newline at end of file +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CustomerProfileRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CustomerProfileRepository.cs new file mode 100644 index 0000000..3f703d9 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CustomerProfileRepository.cs @@ -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, ICustomerProfileRepository +{ + public CustomerProfileRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task GetByUserIdAsync(int userId, CancellationToken cancellationToken) + => Table.FirstOrDefaultAsync(c => c.UserId == userId, cancellationToken); + + public Task AddAsync(CustomerProfile profile, CancellationToken cancellationToken) + => base.AddAsync(profile); + + public Task 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 GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken) + => TableNoTracking + .Where(c => c.UserId == userId) + .Select(c => (long?)c.Id) + .FirstOrDefaultAsync(cancellationToken); +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseBankAccountRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseBankAccountRepository.cs new file mode 100644 index 0000000..9dad7f6 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseBankAccountRepository.cs @@ -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, INurseBankAccountRepository +{ + public NurseBankAccountRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task AddAsync(NurseBankAccount account, CancellationToken cancellationToken) + => base.AddAsync(account); + + public Task 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 IbanHashExistsAsync(string ibanHash, CancellationToken cancellationToken) + => TableNoTracking.AnyAsync(a => a.IbanHash == ibanHash, cancellationToken); + + public Task HasAnyAsync(long nurseId, CancellationToken cancellationToken) + => TableNoTracking.AnyAsync(a => a.NurseId == nurseId, cancellationToken); + + public async Task> 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(); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseProfileRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseProfileRepository.cs new file mode 100644 index 0000000..56a2681 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseProfileRepository.cs @@ -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, INurseProfileRepository +{ + public NurseProfileRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task GetByUserIdAsync(int userId, CancellationToken cancellationToken) + => Table.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken); + + public Task AddAsync(NurseProfile profile, CancellationToken cancellationToken) + => base.AddAsync(profile); + + public Task 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 GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken) + => TableNoTracking + .Where(p => p.UserId == userId) + .Select(p => (long?)p.Id) + .FirstOrDefaultAsync(cancellationToken); + + public Task GetIdentityContextByUserIdAsync(int userId, CancellationToken cancellationToken) + => TableNoTracking + .Where(p => p.UserId == userId) + .Select(p => new NurseIdentityContext(p.Id, p.User.NationalId)) + .FirstOrDefaultAsync(cancellationToken); +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PatientRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PatientRepository.cs new file mode 100644 index 0000000..47dc851 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PatientRepository.cs @@ -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, IPatientRepository +{ + public PatientRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task AddAsync(Patient patient, CancellationToken cancellationToken) + => base.AddAsync(patient); + + public Task GetOwnedAsync(long id, long customerId, CancellationToken cancellationToken) + => Table.FirstOrDefaultAsync(p => p.Id == id && p.CustomerId == customerId, cancellationToken); + + public async Task> 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(items, total, page, pageSize); + } + + public Task 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); +} diff --git a/server/src/Tests/Baya.Test.Api/CustomerProfilesApiTests.cs b/server/src/Tests/Baya.Test.Api/CustomerProfilesApiTests.cs new file mode 100644 index 0000000..40fd3e7 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/CustomerProfilesApiTests.cs @@ -0,0 +1,44 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class CustomerProfilesApiTests(BayaApiFactory factory) : IClassFixture +{ + [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); + } +} diff --git a/server/src/Tests/Baya.Test.Api/NurseBankAccountsApiTests.cs b/server/src/Tests/Baya.Test.Api/NurseBankAccountsApiTests.cs new file mode 100644 index 0000000..fc7539a --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/NurseBankAccountsApiTests.cs @@ -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 +{ + 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 accounts, long id) => + accounts.Single(a => a.GetProperty("id").GetInt64() == id).GetProperty("isPrimary").GetBoolean(); +} diff --git a/server/src/Tests/Baya.Test.Api/NurseProfilesApiTests.cs b/server/src/Tests/Baya.Test.Api/NurseProfilesApiTests.cs new file mode 100644 index 0000000..ee6620d --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/NurseProfilesApiTests.cs @@ -0,0 +1,64 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class NurseProfilesApiTests(BayaApiFactory factory) : IClassFixture +{ + [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); + } +} diff --git a/server/src/Tests/Baya.Test.Api/PatientsApiTests.cs b/server/src/Tests/Baya.Test.Api/PatientsApiTests.cs new file mode 100644 index 0000000..450daf2 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/PatientsApiTests.cs @@ -0,0 +1,90 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class PatientsApiTests(BayaApiFactory factory) : IClassFixture +{ + 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); + } +} diff --git a/server/src/Tests/Baya.Test.Api/ProfileTestClient.cs b/server/src/Tests/Baya.Test.Api/ProfileTestClient.cs new file mode 100644 index 0000000..3783973 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/ProfileTestClient.cs @@ -0,0 +1,27 @@ +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +/// +/// 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. +/// +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()!); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Identity/NurseBankAccountHandlersTests.cs b/server/src/Tests/Baya.Test.Foundation/Identity/NurseBankAccountHandlersTests.cs new file mode 100644 index 0000000..a965da7 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Identity/NurseBankAccountHandlersTests.cs @@ -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(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly INurseProfileRepository _nurses = Substitute.For(); + private readonly INurseBankAccountRepository _accounts = Substitute.For(); + private readonly IFieldEncryptor _encryptor = Substitute.For(); + private readonly IBankAccountOwnershipVerifier _verifier = Substitute.For(); + + public NurseBankAccountHandlersTests() + { + _currentUser.UserId.Returns(7); + _currentUser.Roles.Returns([RoleNames.Nurse]); + _unitOfWork.NurseProfileRepository.Returns(_nurses); + _unitOfWork.NurseBankAccountRepository.Returns(_accounts); + _nurses.GetIdentityContextByUserIdAsync(7, Arg.Any()) + .Returns(new NurseIdentityContext(42L, "0012345678")); + _encryptor.Hash(Arg.Any()).Returns(ci => "HASH-" + ci.Arg()); + } + + private AddNurseBankAccountCommandHandler CreateAddHandler() => + new(_currentUser, _unitOfWork, _encryptor, _verifier); + + [Fact] + public async Task Add_MatchingIban_RunsInquiryAndSetsMatchedTrueAndPrimary() + { + _accounts.IbanHashExistsAsync(Arg.Any(), Arg.Any()).Returns(false); + _accounts.HasAnyAsync(42L, Arg.Any()).Returns(false); + _verifier.VerifyOwnershipAsync(Arg.Any(), "0012345678", Arg.Any()) + .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()); + await _accounts.Received(1).AddAsync( + Arg.Is(a => a.NurseId == 42L && a.MatchedNationalId == true && a.OwnershipVendorRef == "MOCK-SHEBA-ABC" && a.IbanHash == "HASH-" + ValidIban), + Arg.Any()); + await _unitOfWork.Received(1).CommitAsync(); + } + + [Fact] + public async Task Add_MismatchIban_RecordsMatchedFalse() + { + _accounts.IbanHashExistsAsync(Arg.Any(), Arg.Any()).Returns(false); + _accounts.HasAnyAsync(42L, Arg.Any()).Returns(true); + _verifier.VerifyOwnershipAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .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()).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(), Arg.Any()); + await _verifier.DidNotReceive().VerifyOwnershipAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _unitOfWork.DidNotReceive().CommitAsync(); + } + + [Fact] + public async Task Add_NoNurseProfile_IsFailure() + { + _nurses.GetIdentityContextByUserIdAsync(7, Arg.Any()).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(), Arg.Any()); + } + + [Fact] + public async Task SetPrimary_OwnedNonPrimary_FlipsAtomically() + { + _nurses.GetProfileIdByUserIdAsync(7, Arg.Any()).Returns(42L); + _accounts.GetOwnedAsync(5L, 42L, Arg.Any()).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()); + } + + [Fact] + public async Task SetPrimary_NotOwned_IsNotFound() + { + _nurses.GetProfileIdByUserIdAsync(7, Arg.Any()).Returns(42L); + _accounts.GetOwnedAsync(5L, 42L, Arg.Any()).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(), Arg.Any(), Arg.Any()); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Identity/NurseProfileHandlersTests.cs b/server/src/Tests/Baya.Test.Foundation/Identity/NurseProfileHandlersTests.cs new file mode 100644 index 0000000..5006eaf --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Identity/NurseProfileHandlersTests.cs @@ -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(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly INurseProfileRepository _repo = Substitute.For(); + + 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()).ReturnsNull(); + _repo.GetMineAsync(7, Arg.Any()) + .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(p => p.UserId == 7 && !p.IsVerified && !p.IsAcceptingBookings), + Arg.Any()); + 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(), Arg.Any()); + await _unitOfWork.DidNotReceive().CommitAsync(); + } + + [Fact] + public async Task SetAcceptingBookings_NoProfile_IsNotFound() + { + _repo.GetByUserIdAsync(7, Arg.Any()).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()).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(); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Identity/PatientHandlersTests.cs b/server/src/Tests/Baya.Test.Foundation/Identity/PatientHandlersTests.cs new file mode 100644 index 0000000..023c90a --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Identity/PatientHandlersTests.cs @@ -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(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly ICustomerProfileRepository _customers = Substitute.For(); + private readonly IPatientRepository _patients = Substitute.For(); + + 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()).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(p => p.CustomerId == 42L && p.IsActive), Arg.Any()); + await _customers.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + await _unitOfWork.Received(1).CommitAsync(); + } + + [Fact] + public async Task Create_NoCustomerProfileYet_AutoProvisionsProfile() + { + _customers.GetProfileIdByUserIdAsync(7, Arg.Any()).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(c => c.UserId == 7), Arg.Any()); + await _patients.Received(1).AddAsync(Arg.Any(), Arg.Any()); + } + + [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(), Arg.Any()); + } + + [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()).Returns(42L); + _patients.GetOwnedAsync(99L, 42L, Arg.Any()).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()).Returns(42L); + _patients.GetOwnedProjectedAsync(99L, 42L, Arg.Any()).ReturnsNull(); + var handler = new GetPatientQueryHandler(_currentUser, _unitOfWork); + + var result = await handler.Handle(new GetPatientQuery(99L), CancellationToken.None); + + Assert.True(result.IsNotFound); + } +}