From 1c266523bc02272c2e5526079cec737930781357 Mon Sep 17 00:00:00 2001 From: hamid Date: Sun, 5 Jul 2026 14:39:32 +0330 Subject: [PATCH] backend phase 6: nurse verification & credentials (mocked vendors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trust engine. New `verif` schema (5 tables) + a data-driven verification pipeline: steps are rows (6 seeded step-types), not a code enum. - nurse_verifications.status is the single source of verification truth; nurse_profiles.is_verified is flipped ONLY inside the finalize transaction (VerificationAggregator: tracked verification + tracked profile -> one commit) and reversed on suspension/expiry — no in-between state. - is_automated snapshotted onto each step at submit; steps seeded from active required step-types; automated runs (identity-KYC, Shahkar, IBAN ownership) find their step by code. - users.national_id populated only on identity-KYC pass; Shahkar + IBAN owner compare against it (money-mule guard); shared-SIM -> shared_sim support alert. - Documents are metadata-only behind signed URLs; credential_number encrypted and never serialized; public trust badge exposes credential TYPES, not numbers; holder-name cross-checked against the verified identity before recording. - Admin-triggered credential-expiry scan reverts lapsed steps, re-gates bookability, raises a verification_expired alert + verification_expiry_prompt notification (scheduled cron deferred; config key verification_expiry_scan_cadence_hours). Three new mock vendor seams (IShahkarVerifier / IIdentityKycProvider / ICredentialVerifier) behind DI; reuses b3 IBankAccountOwnershipVerifier and b0 IObjectStorage/IFieldEncryptor. 15 endpoints across 4 controllers. Two migrations (tables + step-type seed). 154 tests pass, zero new warnings. Contract dev/contracts/domains/verification.md + swagger snapshot refreshed; handoff/report/mocks-registry updated. Co-Authored-By: Claude Opus 4.8 --- dev/contracts/domains/verification.md | 257 ++ dev/contracts/openapi/swagger.v1.json | 2022 +++++++++- dev/shared-working-context/backend/STATUS.md | 28 + .../backend/handoff/after-backend-phase-6.md | 90 + .../reports/backend-phase-6-report.md | 126 + .../reports/mocks-registry.md | 10 +- product/business/02-nurse-verification.html | 8 + product/business/02-nurse-verification.md | 7 + server/CLAUDE.md | 11 +- server/CONVENTIONS.md | 20 + .../AdminVerificationStepTypesController.cs | 40 + .../V1/AdminVerificationsController.cs | 53 + .../V1/NurseVerificationController.cs | 60 + .../Controllers/V1/NursesController.cs | 25 + .../Common/IdentityNameMatch.cs | 45 + .../Common/VerificationAggregator.cs | 77 + .../Contracts/Common/ICredentialVerifier.cs | 32 + .../Contracts/Common/IIdentityKycProvider.cs | 27 + .../Contracts/Common/IShahkarVerifier.cs | 27 + .../INurseBankAccountRepository.cs | 4 + .../Persistence/INurseProfileRepository.cs | 4 + .../Contracts/Persistence/IUnitOfWork.cs | 1 + .../Persistence/IVerificationRepository.cs | 61 + .../ConfirmDocumentUploadCommand.Handler.cs | 68 + .../ConfirmDocumentUploadCommand.Validator.cs | 16 + .../ConfirmDocumentUploadCommand.cs | 18 + .../AdminDeactivateStepTypeCommand.Handler.cs | 24 + .../AdminDeactivateStepTypeCommand.cs | 8 + ...RequestDocumentUploadUrlCommand.Handler.cs | 43 + ...questDocumentUploadUrlCommand.Validator.cs | 13 + .../RequestDocumentUploadUrlCommand.cs | 11 + .../AdminReviewStepCommand.Handler.cs | 144 + .../AdminReviewStepCommand.Validator.cs | 22 + .../ReviewStep/AdminReviewStepCommand.cs | 23 + ...nBankAccountVerificationCommand.Handler.cs | 84 + .../RunBankAccountVerificationCommand.cs | 10 + .../RunIdentityKycCommand.Handler.cs | 75 + .../RunIdentityKycCommand.Validator.cs | 14 + .../RunIdentityKyc/RunIdentityKycCommand.cs | 12 + .../RunShahkarMatchCommand.Handler.cs | 87 + .../RunShahkarMatch/RunShahkarMatchCommand.cs | 10 + .../ScanExpiringCredentialsCommand.Handler.cs | 86 + .../ScanExpiringCredentialsCommand.cs | 11 + .../SubmitNurseVerificationCommand.Handler.cs | 75 + .../SubmitNurseVerificationCommand.cs | 12 + ...AdminSuspendVerificationCommand.Handler.cs | 58 + ...minSuspendVerificationCommand.Validator.cs | 12 + .../AdminSuspendVerificationCommand.cs | 9 + .../AdminUpsertStepTypeCommand.Handler.cs | 74 + .../AdminUpsertStepTypeCommand.Validator.cs | 20 + .../AdminUpsertStepTypeCommand.cs | 22 + ...GetNurseVerificationStatusQuery.Handler.cs | 35 + .../GetNurseVerificationStatusQuery.cs | 8 + .../GetVerifiedTrustBadgeQuery.Handler.cs | 33 + .../GetVerifiedTrustBadgeQuery.cs | 9 + ...AdminGetVerificationDetailQuery.Handler.cs | 23 + .../AdminGetVerificationDetailQuery.cs | 10 + .../AdminListPendingStepsQuery.Handler.cs | 26 + .../AdminListPendingStepsQuery.cs | 11 + .../AdminListStepTypesQuery.Handler.cs | 26 + .../ListStepTypes/AdminListStepTypesQuery.cs | 10 + .../Verification/VerificationCache.cs | 37 + .../Verification/AdminVerificationDtos.cs | 36 + .../Models/Verification/VerificationDtos.cs | 60 + .../Verification/VerificationResults.cs | 17 + .../Entities/SupportAlerts/SupportAlert.cs | 3 +- .../Entities/Verification/NurseCredential.cs | 42 + .../Verification/NurseVerification.cs | 39 + .../Verification/VerificationCodes.cs | 66 + .../Verification/VerificationDocument.cs | 29 + .../Verification/VerificationStatus.cs | 16 + .../Entities/Verification/VerificationStep.cs | 37 + .../Verification/VerificationStepStatus.cs | 16 + .../Verification/VerificationStepType.cs | 29 + .../Verification/VerificationStepTypeCodes.cs | 48 + .../Seams/MockCredentialVerifier.cs | 23 + .../Seams/MockIdentityKycProvider.cs | 57 + .../Seams/MockShahkarVerifier.cs | 68 + .../Seams/SeamOptions.cs | 32 + .../ServiceCollectionExtension.cs | 7 + .../ApplicationDbContext.cs | 8 + .../PlatformConfigConfig.cs | 1 + .../NurseCredentialConfig.cs | 38 + .../NurseVerificationConfig.cs | 38 + .../VerificationDocumentConfig.cs | 32 + .../VerificationStepConfig.cs | 38 + .../VerificationStepTypeConfig.cs | 32 + .../VerificationStepTypeSeed.cs | 54 + ...702193307_VerificationPipeline.Designer.cs | 3346 ++++++++++++++++ .../20260702193307_VerificationPipeline.cs | 302 ++ ...3432_SeedVerificationStepTypes.Designer.cs | 3423 +++++++++++++++++ ...0260702193432_SeedVerificationStepTypes.cs | 84 + .../ApplicationDbContextModelSnapshot.cs | 493 +++ .../Repositories/Common/UnitOfWork.cs | 2 + .../NurseBankAccountRepository.cs | 3 + .../Repositories/NurseProfileRepository.cs | 3 + .../Repositories/VerificationRepository.cs | 301 ++ .../AdminVerificationApiTests.cs | 98 + .../NurseVerificationApiTests.cs | 69 + .../Baya.Test.Api/PublicTrustBadgeApiTests.cs | 34 + .../AdminVerificationHandlersTests.cs | 206 + .../Verification/RunStepHandlersTests.cs | 146 + .../SubmitNurseVerificationHandlerTests.cs | 90 + .../VerificationAggregatorTests.cs | 120 + .../Verification/VerificationTestSupport.cs | 38 + 105 files changed, 13938 insertions(+), 10 deletions(-) create mode 100644 dev/contracts/domains/verification.md create mode 100644 dev/shared-working-context/backend/handoff/after-backend-phase-6.md create mode 100644 dev/shared-working-context/reports/backend-phase-6-report.md create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/AdminVerificationStepTypesController.cs create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/AdminVerificationsController.cs create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/NurseVerificationController.cs create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/NursesController.cs create mode 100644 server/src/Core/Baya.Application/Common/IdentityNameMatch.cs create mode 100644 server/src/Core/Baya.Application/Common/VerificationAggregator.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Common/ICredentialVerifier.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Common/IIdentityKycProvider.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Common/IShahkarVerifier.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Persistence/IVerificationRepository.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/ConfirmDocumentUpload/ConfirmDocumentUploadCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/ConfirmDocumentUpload/ConfirmDocumentUploadCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/ConfirmDocumentUpload/ConfirmDocumentUploadCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/DeactivateStepType/AdminDeactivateStepTypeCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/DeactivateStepType/AdminDeactivateStepTypeCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/RequestDocumentUploadUrl/RequestDocumentUploadUrlCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/RequestDocumentUploadUrl/RequestDocumentUploadUrlCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/RequestDocumentUploadUrl/RequestDocumentUploadUrlCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/ReviewStep/AdminReviewStepCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/ReviewStep/AdminReviewStepCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/ReviewStep/AdminReviewStepCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/RunBankAccountVerification/RunBankAccountVerificationCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/RunBankAccountVerification/RunBankAccountVerificationCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/RunIdentityKyc/RunIdentityKycCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/RunIdentityKyc/RunIdentityKycCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/RunIdentityKyc/RunIdentityKycCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/RunShahkarMatch/RunShahkarMatchCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/RunShahkarMatch/RunShahkarMatchCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/ScanExpiringCredentials/ScanExpiringCredentialsCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/ScanExpiringCredentials/ScanExpiringCredentialsCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/SubmitVerification/SubmitNurseVerificationCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/SubmitVerification/SubmitNurseVerificationCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/SuspendVerification/AdminSuspendVerificationCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/SuspendVerification/AdminSuspendVerificationCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/SuspendVerification/AdminSuspendVerificationCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/UpsertStepType/AdminUpsertStepTypeCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/UpsertStepType/AdminUpsertStepTypeCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/UpsertStepType/AdminUpsertStepTypeCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Queries/GetStatus/GetNurseVerificationStatusQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Queries/GetStatus/GetNurseVerificationStatusQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Queries/GetTrustBadge/GetVerifiedTrustBadgeQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Queries/GetTrustBadge/GetVerifiedTrustBadgeQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Queries/GetVerificationDetail/AdminGetVerificationDetailQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Queries/GetVerificationDetail/AdminGetVerificationDetailQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Queries/ListPendingSteps/AdminListPendingStepsQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Queries/ListPendingSteps/AdminListPendingStepsQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Queries/ListStepTypes/AdminListStepTypesQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Queries/ListStepTypes/AdminListStepTypesQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/VerificationCache.cs create mode 100644 server/src/Core/Baya.Application/Models/Verification/AdminVerificationDtos.cs create mode 100644 server/src/Core/Baya.Application/Models/Verification/VerificationDtos.cs create mode 100644 server/src/Core/Baya.Application/Models/Verification/VerificationResults.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Verification/NurseCredential.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Verification/NurseVerification.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Verification/VerificationCodes.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Verification/VerificationDocument.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Verification/VerificationStatus.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Verification/VerificationStep.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Verification/VerificationStepStatus.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Verification/VerificationStepType.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Verification/VerificationStepTypeCodes.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockCredentialVerifier.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockIdentityKycProvider.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockShahkarVerifier.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/NurseCredentialConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/NurseVerificationConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/VerificationDocumentConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/VerificationStepConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/VerificationStepTypeConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/VerificationStepTypeSeed.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702193307_VerificationPipeline.Designer.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702193307_VerificationPipeline.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702193432_SeedVerificationStepTypes.Designer.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702193432_SeedVerificationStepTypes.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/VerificationRepository.cs create mode 100644 server/src/Tests/Baya.Test.Api/AdminVerificationApiTests.cs create mode 100644 server/src/Tests/Baya.Test.Api/NurseVerificationApiTests.cs create mode 100644 server/src/Tests/Baya.Test.Api/PublicTrustBadgeApiTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Verification/AdminVerificationHandlersTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Verification/RunStepHandlersTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Verification/SubmitNurseVerificationHandlerTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Verification/VerificationAggregatorTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Verification/VerificationTestSupport.cs diff --git a/dev/contracts/domains/verification.md b/dev/contracts/domains/verification.md new file mode 100644 index 0000000..03496cd --- /dev/null +++ b/dev/contracts/domains/verification.md @@ -0,0 +1,257 @@ +# Contract — Nurse verification & credentials (backend phase b6) + +> The trust engine: a data-driven verification pipeline (checklist of steps), the admin review queue, the +> structured credential registry, the transactional `nurse_profiles.is_verified` flip, the admin-triggered +> credential-expiry scan, and the public trust badge. 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) (refreshed for b6 — all 15 endpoints). + +**Status:** live as of backend-phase-b6 · **Frontend consumer:** frontend-phase-f5-b6 (public trust badge → f6) + +> **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased) to match the codebase +> convention and the dynamic-permission key scheme — e.g. `POST api/v1/nurse_verification/submit`, +> `POST api/v1/admin_verifications/steps/{stepId}/decide`. Mutations use **POST**; ids come from the +> **route**, never the body. All responses use the standard `ApiResult` envelope (payload under `data`); +> JSON bodies/fields are **camelCase**. + +## Enums used +- `verification_status` (`nurse_verifications.status` — the aggregate): `not_started` | `pending` | + `in_review` | `approved` | `rejected` | `suspended`. **This is the single source of verification truth.** +- `verification_step_status` (`verification_steps.status`): `not_started` | `pending` | `in_review` | + `passed` | `failed` | `expired`. +- `step_type_code` (the six seeded, **stable** codes): `identity_kyc` | `shahkar_match` | + `moh_competency_license` | `ino_membership` | `criminal_record` | `bank_account_verification`. +- `credential_type`: `moh_competency_license` | `ino_membership` | `criminal_record` — the three + credential-bearing steps that record a `nurse_credentials` row on approval. +- `verification_method` (how a credential was verified): `manual` | `portal` | `api`. Today every real + credential resolves `manual` (admin review); `api` is reserved for a future MoH/INO portal lookup. + +## Key semantics (read first) +- **`nurse_verifications.status` is the SINGLE source of verification truth.** `nurse_profiles.is_verified` + is the **only derived boolean** and is flipped **only inside the finalize transaction** (a re-aggregate + after a step decision) or reversed inside a suspend/expiry transaction. The client never sets or infers + `is_verified` — it reads `isBookable` / `isVerified` off the API. +- **A nurse becomes bookable only when the aggregate reaches `approved`.** `VerificationStatusDto.isBookable` + is the flag to gate the nurse UI on; `blockingSteps` names what still stands in the way. +- **Steps are automated or manual.** Automated steps (`isAutomated:true` — `identity_kyc`, `shahkar_match`, + `bank_account_verification`) run via a `/run` endpoint against a mocked vendor seam. Manual steps + (`moh_competency_license`, `ino_membership`, `criminal_record`) take a document upload and wait for an + admin decision. +- **Credentials never leak their number.** `nurse_credentials.credential_number` is **encrypted at rest and + NEVER serialized** on any DTO. The public **trust badge exposes credential TYPES only**, never numbers. +- **Identity is cross-checked.** A credential's `holderName` is checked against the verified identity name; + a mismatch → **400** and **no credential is recorded**. Bank verification enforces a **money-mule guard**: + the IBAN holder's national id must equal the verified nurse national id. +- **Deactivate step-types, never delete.** `DELETE` on a step-type sets `is_active=false`; a step-type + `code` is **immutable once in use**; a duplicate `code` → **409**. +- **Expiry is admin-triggered for now.** Time-limited steps (e.g. `criminal_record`) lapse to `expired`; + `scan_expiring` is the manual entry point that reverts them and re-gates bookability. The scheduled cron is + **deferred** (config key `verification_expiry_scan_cadence_hours`, int, default `24`). +- **All vendor/money calls are mocked** behind DI seams (deterministic) — no real KYC, Shahkar, credential, + or bank call happens. See [Mocks](#mocks). + +## Nurse verification — `NurseVerificationController` (`[Authorize]`; nurse role enforced in handler; tenancy-scoped to the signed-in nurse) + +### `POST api/v1/nurse_verification/submit` +- **Purpose:** open (or re-open) the nurse's verification and seed the checklist. +- **Body:** none. +- **`data`:** `VerificationStatusDto`. +- **Notes:** upserts the `nurse_verifications` header and **seeds one `verification_step` per active required + step-type** (snapshotting `is_automated` at seed time). **Idempotent** — never duplicates a step; adds only + newly-required ones on a re-submit. `400` if the caller has no nurse profile; `401` unauthenticated; + `403` non-nurse. + +### `GET api/v1/nurse_verification` +- **Purpose:** the nurse's own checklist + aggregate status + blocking summary. +- **`data`:** `VerificationStatusDto` — `status`, `isBookable`, `blockingSteps` (step codes still blocking), + `steps[]`. Returns a **`not_started` empty checklist** if the nurse never submitted (not a 404). + +### `POST api/v1/nurse_verification/steps/{stepId}/upload_url` +- **Purpose:** get a signed PUT URL for a manual step's document. +- **Body:** `{ contentType, fileName? }`. +- **`data`:** `UploadUrlResult` (`objectStorageKey`, `uploadUrl`). **Manual (non-automated) steps only.** + Echo `objectStorageKey` back on confirm. `400` on an automated step / bad content type; `404` if the step + isn't the caller's. + +### `POST api/v1/nurse_verification/steps/{stepId}/documents` +- **Purpose:** confirm an uploaded document and move the manual step to `in_review`. +- **Body:** `{ objectStorageKey, integrityHash, contentType, fileSizeBytes, originalFileName? }`. +- **`data`:** `DocumentConfirmedResult` (`documentId`, `stepStatus`). +- **Notes:** persists a `verification_documents` **metadata row only** (bytes never touch the DB) and moves + the manual step to `in_review`. `404` if the step isn't the caller's. + +### `POST api/v1/nurse_verification/steps/identity_kyc/run` +- **Purpose:** run the automated national-ID + liveness check. +- **Body:** `{ nationalId (10 digits), livenessPayload? }`. +- **`data`:** `RunStepResult` (`stepId`, `stepStatus`, `failureReason?`). +- **Side effects:** on **pass** populates `users.national_id` + `users.national_id_verified_at`. `400` on a + malformed national id; a vendor fail comes back as `stepStatus:"failed"` + `failureReason` (still `200`). + +### `POST api/v1/nurse_verification/steps/shahkar_match/run` +- **Purpose:** run the phone↔national-id Shahkar match. +- **Body:** none. +- **`data`:** `RunStepResult`. +- **Notes:** **requires identity KYC passed** (a verified national id must be present) → else `400`. + **Shared-SIM is an explicit handled failure** — it fails the step and raises a `shared_sim` **support + alert**. On **pass** sets `users.shahkar_verified_at`. + +### `POST api/v1/nurse_verification/steps/bank_account_verification/run` +- **Purpose:** run the استعلام شبا IBAN-owner ↔ national-id match (money-mule guard). +- **Body:** none. +- **`data`:** `RunStepResult`. +- **Notes:** requires KYC passed **and** a **primary `nurse_bank_accounts` row** → else `400`. Reuses the b3 + `IBankAccountOwnershipVerifier`; the **holder national id must equal the verified nurse national id**. On + match sets the account's `matched_national_id=1` (the b13 first-payout gate). + +## Admin step-type catalog — `AdminVerificationStepTypesController` (`[Authorize(DynamicPermission)]`, rate-limited `sensitive`) + +### `GET api/v1/admin_verification_step_types?includeInactive={bool}` +- **`data`:** `IReadOnlyList`. **Cached** (generation-token; any write invalidates). + `includeInactive=false` (default) hides deactivated step-types. + +### `POST api/v1/admin_verification_step_types` +- **Purpose:** create or update a step-type (upsert on `id`). +- **Body:** `{ id?, code, displayName, description?, isRequired, isAutomated, automationProvider?, sortOrder, isActive }`. + `id` **null → create**; else update. +- **`data`:** `VerificationStepTypeDto`. +- **Notes:** `code` must be **snake_case** (`[a-z][a-z0-9_]*`) and is **immutable once the step-type is in + use**. `400` invalid code/labels; `404` unknown `id` on update; **`409`** duplicate `code`. + +### `DELETE api/v1/admin_verification_step_types/{id}` +- **Purpose:** **deactivate** a step-type (`is_active=false`) — **never a hard delete**. +- **`data`:** `bool` (success). `404` unknown id. + +## Admin review queue — `AdminVerificationsController` (`[Authorize(DynamicPermission)]`, rate-limited `sensitive`) + +### `GET api/v1/admin_verifications?status=&page=&page_size=` +- **Purpose:** the review queue — one row per step awaiting attention. +- **Params:** `status` (default `in_review`) + pagination `page`/`page_size`. +- **`data`:** `PagedResult`. Documents carry **signed GET URLs**. + +### `GET api/v1/admin_verifications/{nurseVerificationId}` +- **Purpose:** the full case for a nurse. +- **`data`:** `AdminVerificationDetailDto` — all steps + their documents (signed URLs) + credentials + the + **identity name** for cross-check. `404` if the verification doesn't exist. + +### `POST api/v1/admin_verifications/steps/{stepId}/decide` +- **Purpose:** approve or reject a manual step (and, on a credential-bearing step, record the credential). +- **Body:** `{ approve, rejectionReason?, credentialNumber?, holderName?, issuingAuthority?, issuedAt?, expiresAt?, verificationSource? }` + — `rejectionReason` **required when `approve=false`**. +- **`data`:** `ReviewStepResult` (`stepId`, `stepStatus`, `credentialId?`). +- **Notes:** **manual steps only.** On **approving a credential-bearing step** + (`moh_competency_license` / `ino_membership` / `criminal_record`) it records a `nurse_credentials` row — + `credential_number` **ENCRYPTED** (never serialized); `holderName` **cross-checked** against the verified + identity name (**mismatch → 400, no credential recorded**); **`criminal_record` requires `expiresAt`**. + Writes an `audit_logs` decision record, then **re-aggregates** the verification (**may flip + `is_verified`**). `400` missing `rejectionReason` / holder-name mismatch / missing required `expiresAt`; + `404` step not found. + +### `POST api/v1/admin_verifications/{nurseVerificationId}/suspend` +- **Purpose:** suspend a verified nurse. +- **Body:** `{ reason }`. +- **`data`:** `bool`. +- **Notes:** sets `status=suspended` and **reverses `is_verified=0` in the same transaction**. Writes an + `audit_logs` record. `404` unknown verification. + +### `POST api/v1/admin_verifications/scan_expiring` +- **Purpose:** the admin-triggered credential-expiry scan (the cron entry point until the scheduler ships). +- **Body:** `{ page?, pageSize? }`. +- **`data`:** `ScanExpiringResult` (`scannedSteps`, `revertedNurses`). +- **Notes:** reverts lapsed time-limited steps to `expired`, raises a `verification_expired` **support + alert** + a `verification_expiry_prompt` **notification**, and **re-gates bookability**. The scheduled cron + is **deferred** (config `verification_expiry_scan_cadence_hours`, int, default `24`). + +## Public trust badge — `NursesController` (`[AllowAnonymous]`) + +### `GET api/v1/nurses/{nurseId}/trust_badge` +- **Purpose:** the public trust signal for a nurse. +- **`data`:** `TrustBadgeDto` — `isVerified`, `approvedAt?`, and the **credential TYPES held** (never the + numbers). **Cached** (short TTL; evicted on suspension / expiry / a step decision). `404` for an unknown + nurse. + +## Shared shapes +_(records; camelCase on the wire; `?` = nullable; `credential_number` is never present)_ + +- `VerificationStepTypeDto`: `id` (long), `code` (string), `displayName` (string), `description` (string?), + `isRequired` (bool), `isAutomated` (bool), `automationProvider` (string?), `sortOrder` (int), + `isActive` (bool). +- `VerificationStepDto`: `id` (long), `code` (string), `displayName` (string), `status` (enum), `isAutomated` + (bool), `expiresAt` (datetime?), `failureReason` (string?). +- `VerificationStatusDto`: `status` (enum), `isBookable` (bool), `blockingSteps` (string[] — step codes), + `steps` (`VerificationStepDto[]`). +- `VerificationDocumentDto`: `id` (long), `contentType` (string), `fileSizeBytes` (long), `originalFileName` + (string?), `url` (string — a **short-lived signed** URL). +- `NurseCredentialDto`: `id` (long), `credentialType` (enum), `holderNameSnapshot` (string), + `issuingAuthority` (string), `issuedAt` (date?), `expiresAt` (date?), `verificationMethod` (enum). + **`credential_number` is NEVER serialized.** +- `TrustBadgeDto`: `nurseId` (long), `isVerified` (bool), `approvedAt` (datetime?), `credentialTypes` + (string[] — credential **types** only). +- `AdminPendingStepDto`: `nurseVerificationId` (long), `nurseId` (long), `nurseName` (string), `stepId` + (long), `stepCode` (string), `stepDisplayName` (string), `status` (enum), `submittedAt` (datetime?), + `documents` (`VerificationDocumentDto[]`). +- `AdminStepDetailDto`: `stepId` (long), `code` (string), `displayName` (string), `status` (enum), + `isAutomated` (bool), `expiresAt` (datetime?), `failureReason` (string?), `documents` + (`VerificationDocumentDto[]`). +- `AdminVerificationDetailDto`: `nurseVerificationId` (long), `nurseId` (long), `identityName` (string), + `status` (enum), `steps` (`AdminStepDetailDto[]`), `credentials` (`NurseCredentialDto[]`). +- `UploadUrlResult`: `objectStorageKey` (string), `uploadUrl` (string). +- `DocumentConfirmedResult`: `documentId` (long), `stepStatus` (enum). +- `RunStepResult`: `stepId` (long), `stepStatus` (enum), `failureReason` (string?). +- `ReviewStepResult`: `stepId` (long), `stepStatus` (enum), `credentialId` (long?). +- `ScanExpiringResult`: `scannedSteps` (int), `revertedNurses` (int). +- `PagedResult`: `items` (`T[]`), `total` (int), `page` (int), `pageSize` (int). + +## Side effects to know +The finalize/reverse of `nurse_profiles.is_verified` (one transaction) · `users.national_id` population · +`users.shahkar_verified_at` · `nurse_bank_accounts.matched_national_id` · `support_alerts` (`shared_sim`, +`verification_expired`) · `notifications` (`verification_expiry_prompt`) · `audit_logs` decision records. + +## Mocks +All vendor/money calls are **mocked behind DI seams** (deterministic — use the test values below). See +[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md). + +- `IShahkarVerifier` → `MockShahkarVerifier`: **pass** unless the configured shared-SIM phone `09120000000` + (→ shared-SIM handled failure) or the mismatch national id `1111111111`. +- `IIdentityKycProvider` → `MockIdentityKycProvider`: passes any well-formed 10-digit national id **except** + the configured fail id `0000000000`. +- `ICredentialVerifier` → `MockCredentialVerifier`: the manual-admin default — always `RequiresManualReview` + / `verification_method=manual`. +- **Reused:** `IBankAccountOwnershipVerifier` (b3; mismatch IBAN `IR000000000000000000000000` → mismatch), + `IObjectStorage` (b0; local-disk, signed PUT/GET URLs), `IFieldEncryptor` (b0; encrypts + `credential_number`). + +## Example — a nurse gets verified +``` +# 1) open the checklist +POST /api/v1/nurse_verification/submit -> data.steps seeded (one per active required step-type) +GET /api/v1/nurse_verification -> { status: "pending", isBookable: false, blockingSteps: [...] } + +# 2) automated identity + shahkar +POST /api/v1/nurse_verification/steps/identity_kyc/run { "nationalId": "1234567891" } -> stepStatus "passed" +POST /api/v1/nurse_verification/steps/shahkar_match/run -> stepStatus "passed" +# (a 09120000000 SIM would come back "failed" + raise a shared_sim support alert) + +# 3) manual credential (e.g. MoH license): upload then wait for admin +POST /api/v1/nurse_verification/steps/{stepId}/upload_url { "contentType": "application/pdf" } + -> { objectStorageKey, uploadUrl } # PUT the bytes to uploadUrl +POST /api/v1/nurse_verification/steps/{stepId}/documents { objectStorageKey, integrityHash, contentType, fileSizeBytes } + -> step -> in_review + +# 4) admin decides -> records the (encrypted) credential, re-aggregates, may flip is_verified +POST /api/v1/admin_verifications/steps/{stepId}/decide + { "approve": true, "credentialNumber": "…", "holderName": "", "issuingAuthority": "MoH", "issuedAt": "2026-01-01" } + -> { credentialId } +# holderName != verified identity -> 400 (no credential recorded) + +# 5) public badge (types only, never numbers) +GET /api/v1/nurses/{nurseId}/trust_badge -> { isVerified: true, approvedAt, credentialTypes: ["moh_competency_license"] } +``` + +## Changelog +- b6 — initial contract: nurse verification checklist (submit/get/upload/confirm/run), automated + identity-KYC / Shahkar / bank-ownership runs, admin step-type catalog (CRUD + deactivate), + admin review queue (list/detail/decide/suspend/scan-expiring), public trust badge; + `verification_status` / `verification_step_status` / `credential_type` / `verification_method` enums; + transactional `is_verified` flip; encrypted-never-serialized `credential_number`; three new mocked vendor + seams (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`). Scheduled expiry cron deferred. diff --git a/dev/contracts/openapi/swagger.v1.json b/dev/contracts/openapi/swagger.v1.json index 7106d57..28b8e0b 100644 --- a/dev/contracts/openapi/swagger.v1.json +++ b/dev/contracts/openapi/swagger.v1.json @@ -7,7 +7,7 @@ }, "servers": [ { - "url": "http://localhost:5099" + "url": "http://localhost" } ], "paths": { @@ -1363,6 +1363,661 @@ ] } }, + "/api/v1/admin_verifications": { + "get": { + "tags": [ + "AdminVerifications" + ], + "operationId": "AdminVerifications_List", + "parameters": [ + { + "name": "Status", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 1 + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 2 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 3 + } + ], + "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/ApiResultOfPagedResultOfAdminPendingStepDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_verifications/{nurseVerificationId}": { + "get": { + "tags": [ + "AdminVerifications" + ], + "summary": "Retrieves a AdminVerification by unique id", + "operationId": "AdminVerifications_Get", + "parameters": [ + { + "name": "nurseVerificationId", + "in": "path", + "required": true, + "description": "A unique id for the AdminVerification", + "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/ApiResultOfAdminVerificationDetailDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_verifications/steps/{stepId}/decide": { + "post": { + "tags": [ + "AdminVerifications" + ], + "operationId": "AdminVerifications_Decide", + "parameters": [ + { + "name": "stepId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminReviewStepCommand" + } + } + }, + "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/ApiResultOfReviewStepResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_verifications/{nurseVerificationId}/suspend": { + "post": { + "tags": [ + "AdminVerifications" + ], + "operationId": "AdminVerifications_Suspend", + "parameters": [ + { + "name": "nurseVerificationId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminSuspendVerificationCommand" + } + } + }, + "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/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_verifications/scan_expiring": { + "post": { + "tags": [ + "AdminVerifications" + ], + "operationId": "AdminVerifications_ScanExpiring", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanExpiringCredentialsCommand" + } + } + }, + "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/ApiResultOfScanExpiringResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_verification_step_types": { + "get": { + "tags": [ + "AdminVerificationStepTypes" + ], + "operationId": "AdminVerificationStepTypes_List", + "parameters": [ + { + "name": "includeInactive", + "in": "query", + "schema": { + "type": "boolean" + }, + "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/ApiResultOfIReadOnlyListOfVerificationStepTypeDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + }, + "post": { + "tags": [ + "AdminVerificationStepTypes" + ], + "operationId": "AdminVerificationStepTypes_Upsert", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminUpsertStepTypeCommand" + } + } + }, + "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/ApiResultOfVerificationStepTypeDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_verification_step_types/{id}": { + "delete": { + "tags": [ + "AdminVerificationStepTypes" + ], + "operationId": "AdminVerificationStepTypes_Deactivate", + "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/audit/get_audit_trail": { "get": { "tags": [ @@ -3947,6 +4602,78 @@ ] } }, + "/api/v1/nurses/{nurseId}/trust_badge": { + "get": { + "tags": [ + "Nurses" + ], + "operationId": "Nurses_TrustBadge", + "parameters": [ + { + "name": "nurseId", + "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/ApiResultOfTrustBadgeDto" + } + } + } + } + } + } + }, "/api/v1/nurse_service_areas/add": { "post": { "tags": [ @@ -4606,6 +5333,522 @@ } } }, + "/api/v1/nurse_verification/submit": { + "post": { + "tags": [ + "NurseVerification" + ], + "operationId": "NurseVerification_Submit", + "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/ApiResultOfVerificationStatusDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/nurse_verification": { + "get": { + "tags": [ + "NurseVerification" + ], + "summary": "Returns all NurseVerifications", + "operationId": "NurseVerification_Get", + "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/ApiResultOfVerificationStatusDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/nurse_verification/steps/{stepId}/upload_url": { + "post": { + "tags": [ + "NurseVerification" + ], + "operationId": "NurseVerification_UploadUrl", + "parameters": [ + { + "name": "stepId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDocumentUploadUrlCommand" + } + } + }, + "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/ApiResultOfUploadUrlResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/nurse_verification/steps/{stepId}/documents": { + "post": { + "tags": [ + "NurseVerification" + ], + "operationId": "NurseVerification_ConfirmDocument", + "parameters": [ + { + "name": "stepId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmDocumentUploadCommand" + } + } + }, + "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/ApiResultOfDocumentConfirmedResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/nurse_verification/steps/identity_kyc/run": { + "post": { + "tags": [ + "NurseVerification" + ], + "operationId": "NurseVerification_RunIdentityKyc", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunIdentityKycCommand" + } + } + }, + "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/ApiResultOfRunStepResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/nurse_verification/steps/shahkar_match/run": { + "post": { + "tags": [ + "NurseVerification" + ], + "operationId": "NurseVerification_RunShahkarMatch", + "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/ApiResultOfRunStepResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/nurse_verification/steps/bank_account_verification/run": { + "post": { + "tags": [ + "NurseVerification" + ], + "operationId": "NurseVerification_RunBankAccountVerification", + "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/ApiResultOfRunStepResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/patients/create": { "post": { "tags": [ @@ -6374,6 +7617,500 @@ } } }, + "ApiResultOfPagedResultOfAdminPendingStepDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfAdminPendingStepDto" + } + ] + } + } + } + ] + }, + "PagedResultOfAdminPendingStepDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/AdminPendingStepDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "AdminPendingStepDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "nurseVerificationId": { + "type": "integer", + "format": "int64" + }, + "nurseId": { + "type": "integer", + "format": "int64" + }, + "nurseName": { + "type": "string" + }, + "stepId": { + "type": "integer", + "format": "int64" + }, + "stepCode": { + "type": "string" + }, + "stepDisplayName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "submittedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VerificationDocumentDto" + } + } + } + }, + "VerificationDocumentDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "contentType": { + "type": "string" + }, + "fileSizeBytes": { + "type": "integer", + "format": "int64" + }, + "originalFileName": { + "type": "string", + "nullable": true + }, + "url": { + "type": "string" + } + } + }, + "ApiResultOfAdminVerificationDetailDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/AdminVerificationDetailDto" + } + ] + } + } + } + ] + }, + "AdminVerificationDetailDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "nurseVerificationId": { + "type": "integer", + "format": "int64" + }, + "nurseId": { + "type": "integer", + "format": "int64" + }, + "identityName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AdminStepDetailDto" + } + }, + "credentials": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NurseCredentialDto" + } + } + } + }, + "AdminStepDetailDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "stepId": { + "type": "integer", + "format": "int64" + }, + "code": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "isAutomated": { + "type": "boolean" + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "failureReason": { + "type": "string", + "nullable": true + }, + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VerificationDocumentDto" + } + } + } + }, + "NurseCredentialDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "credentialType": { + "type": "string" + }, + "holderNameSnapshot": { + "type": "string" + }, + "issuingAuthority": { + "type": "string" + }, + "issuedAt": { + "type": "string", + "format": "date", + "nullable": true + }, + "expiresAt": { + "type": "string", + "format": "date", + "nullable": true + }, + "verificationMethod": { + "type": "string" + } + } + }, + "ApiResultOfReviewStepResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/ReviewStepResult" + } + ] + } + } + } + ] + }, + "ReviewStepResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "stepId": { + "type": "integer", + "format": "int64" + }, + "stepStatus": { + "type": "string" + }, + "credentialId": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, + "AdminReviewStepCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "stepId": { + "type": "integer", + "format": "int64" + }, + "approve": { + "type": "boolean" + }, + "rejectionReason": { + "type": "string", + "nullable": true + }, + "credentialNumber": { + "type": "string", + "nullable": true + }, + "holderName": { + "type": "string", + "nullable": true + }, + "issuingAuthority": { + "type": "string", + "nullable": true + }, + "issuedAt": { + "type": "string", + "format": "date", + "nullable": true + }, + "expiresAt": { + "type": "string", + "format": "date", + "nullable": true + }, + "verificationSource": { + "type": "string", + "nullable": true + } + } + }, + "AdminSuspendVerificationCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "nurseVerificationId": { + "type": "integer", + "format": "int64" + }, + "reason": { + "type": "string", + "nullable": true + } + } + }, + "ApiResultOfScanExpiringResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/ScanExpiringResult" + } + ] + } + } + } + ] + }, + "ScanExpiringResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "scannedSteps": { + "type": "integer", + "format": "int32" + }, + "revertedNurses": { + "type": "integer", + "format": "int32" + } + } + }, + "ScanExpiringCredentialsCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "ApiResultOfIReadOnlyListOfVerificationStepTypeDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/VerificationStepTypeDto" + } + } + } + } + ] + }, + "VerificationStepTypeDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "code": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "isRequired": { + "type": "boolean" + }, + "isAutomated": { + "type": "boolean" + }, + "automationProvider": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + }, + "isActive": { + "type": "boolean" + } + } + }, + "ApiResultOfVerificationStepTypeDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/VerificationStepTypeDto" + } + ] + } + } + } + ] + }, + "AdminUpsertStepTypeCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "code": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "isRequired": { + "type": "boolean" + }, + "isAutomated": { + "type": "boolean" + }, + "automationProvider": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + }, + "isActive": { + "type": "boolean" + } + } + }, "ApiResultOfPagedResultOfAuditLogDto": { "allOf": [ { @@ -7546,6 +9283,51 @@ } } }, + "ApiResultOfTrustBadgeDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/TrustBadgeDto" + } + ] + } + } + } + ] + }, + "TrustBadgeDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "nurseId": { + "type": "integer", + "format": "int64" + }, + "isVerified": { + "type": "boolean" + }, + "approvedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "credentialTypes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "ApiResultOfNurseServiceAreaDto": { "allOf": [ { @@ -7898,6 +9680,244 @@ } } }, + "ApiResultOfVerificationStatusDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/VerificationStatusDto" + } + ] + } + } + } + ] + }, + "VerificationStatusDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "type": "string" + }, + "isBookable": { + "type": "boolean" + }, + "blockingSteps": { + "type": "array", + "items": { + "type": "string" + } + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VerificationStepDto" + } + } + } + }, + "VerificationStepDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "code": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "isAutomated": { + "type": "boolean" + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "failureReason": { + "type": "string", + "nullable": true + } + } + }, + "ApiResultOfUploadUrlResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/UploadUrlResult" + } + ] + } + } + } + ] + }, + "UploadUrlResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "objectStorageKey": { + "type": "string" + }, + "uploadUrl": { + "type": "string" + } + } + }, + "RequestDocumentUploadUrlCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "stepId": { + "type": "integer", + "format": "int64" + }, + "contentType": { + "type": "string" + }, + "fileName": { + "type": "string", + "nullable": true + } + } + }, + "ApiResultOfDocumentConfirmedResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentConfirmedResult" + } + ] + } + } + } + ] + }, + "DocumentConfirmedResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "documentId": { + "type": "integer", + "format": "int64" + }, + "stepStatus": { + "type": "string" + } + } + }, + "ConfirmDocumentUploadCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "stepId": { + "type": "integer", + "format": "int64" + }, + "objectStorageKey": { + "type": "string" + }, + "integrityHash": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "fileSizeBytes": { + "type": "integer", + "format": "int64" + }, + "originalFileName": { + "type": "string", + "nullable": true + } + } + }, + "ApiResultOfRunStepResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/RunStepResult" + } + ] + } + } + } + ] + }, + "RunStepResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "stepId": { + "type": "integer", + "format": "int64" + }, + "stepStatus": { + "type": "string" + }, + "failureReason": { + "type": "string", + "nullable": true + } + } + }, + "RunIdentityKycCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "nationalId": { + "type": "string" + }, + "livenessPayload": { + "type": "string", + "nullable": true + } + } + }, "ApiResultOfPatientDto": { "allOf": [ { diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index 8752657..cf4b5b0 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,34 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## backend-phase-6 — Nurse verification & credentials (mocked vendors) — 2026-07-02 +- **Shipped:** the trust engine via one additive migration — new **`verif`** schema, **5 tables**: + `NurseVerifications` (`status` = the **single source of verification truth**), `VerificationStepTypes` + (seeded catalog — six stable codes `identity_kyc`/`shahkar_match`/`moh_competency_license`/`ino_membership`/ + `criminal_record`/`bank_account_verification`), `VerificationSteps` (one per required step-type; snapshots + `is_automated`), `VerificationDocuments` (**metadata only** — bytes never in the DB), `NurseCredentials` + (`credential_number` **encrypted, never serialized**). **15 endpoints across 4 controllers** — + `nurse_verification` (submit/get/upload_url/documents + automated `identity_kyc`/`shahkar_match`/ + `bank_account_verification` `/run`), `admin_verification_step_types` (list/upsert/deactivate, dup code → + 409), `admin_verifications` (queue/detail/decide/suspend/scan_expiring), public `nurses/{id}/trust_badge`. + `nurse_profiles.is_verified` is the **only derived boolean**, flipped **only inside the finalize + transaction** (reversed transactionally on suspend/expiry). Three **new mock vendor seams** + (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`); reuses b3 + `IBankAccountOwnershipVerifier` + b0 `IObjectStorage`/`IFieldEncryptor`. The expiry-scan logic ships as + `ScanExpiringCredentialsCommand`; the **scheduled cron is deferred** (admin `scan_expiring` is the entry + point; config `verification_expiry_scan_cadence_hours`, default 24). +- **Contracts:** dev/contracts/domains/verification.md + openapi snapshot refreshed (yes — all 15 b6 paths). +- **Mocked:** `IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier` → 🟡 (deterministic mocks; + see reports/mocks-registry.md). All vendor/money calls are mocked. +- **Gate:** build clean (0 new code warnings) / tests green (**153 pass**). Swagger exposes all 15 b6 paths. +- **Handoff:** backend/handoff/after-backend-phase-6.md +- **Notes for frontend:** **`isBookable`/`isVerified` are read-only, server-derived** — never infer + verification client-side. **Credential numbers never cross the wire** (trust badge = types only). + `step.isAutomated` drives the UI (`/run` button vs upload flow). Prereqs enforced with **400** (Shahkar + needs KYC; bank needs KYC + a primary b3 account). Shared-SIM / vendor fails are **200 with + `stepStatus:"failed"` + `failureReason`**, not HTTP errors. Documents are short-lived signed URLs. Routes + are action-style POST; refresh after `select_role`. Public trust badge (f6) is `[AllowAnonymous]`. + ## backend-phase-5 — Service catalog & nurse pricing variants — 2026-07-02 - **Shipped:** five tables via one additive migration (`ServiceCatalogAndNurseVariants`) — new **`catalog`** schema `ServiceCategories` / `ServiceOptionGroups` (nullable `service_category_id` = cross-category) / diff --git a/dev/shared-working-context/backend/handoff/after-backend-phase-6.md b/dev/shared-working-context/backend/handoff/after-backend-phase-6.md new file mode 100644 index 0000000..52a9c85 --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-backend-phase-6.md @@ -0,0 +1,90 @@ +# After backend-phase-6 — nurse verification & credentials are live (vendors mocked) + +The trust engine now exists. A nurse opens a **verification checklist**, clears automated checks +(identity-KYC, Shahkar, bank-ownership) and uploads documents for manual credential steps; an admin reviews, +decides, and — when every required step passes — the platform flips `nurse_profiles.is_verified` **inside a +single transaction**, making the nurse bookable. Credential **numbers are encrypted and never leave the API**; +the public **trust badge** exposes only the credential **types** a nurse holds. Contract: +[`dev/contracts/domains/verification.md`](../../../contracts/domains/verification.md); machine schema: +`dev/contracts/openapi/swagger.v1.json` (refreshed for b6 — all 15 endpoints). **All vendor/money calls are +mocked** behind deterministic DI seams. + +## What the frontend (f5-b6) can now build +- **The nurse verification checklist screen** — `GET api/v1/nurse_verification` returns `status`, + `isBookable`, `blockingSteps[]`, and `steps[]` (each with `code`, `displayName`, `status`, `isAutomated`, + `expiresAt?`, `failureReason?`). `POST api/v1/nurse_verification/submit` opens/seeds it (idempotent). + Requires a nurse profile first (b3 `nurse_profiles/upsert`). Render the checklist off `steps`, gate the + "you're bookable" state on `isBookable`, and surface `blockingSteps` as the to-do list. +- **Identity submit (automated)** — `POST api/v1/nurse_verification/steps/identity_kyc/run` + `{ nationalId, livenessPayload? }` → `RunStepResult` (`stepStatus`, `failureReason?`). A pass populates the + nurse's national id; drive the next steps off it. +- **Shahkar & bank runs (automated)** — `POST .../steps/shahkar_match/run` (no body; **needs KYC passed**) + and `POST .../steps/bank_account_verification/run` (no body; **needs KYC + a primary bank account from b3**). + Both return `RunStepResult`. Shared-SIM comes back as a clean `failed` + reason (not an error) — show it. +- **Document upload for manual steps** — two-step: `POST .../steps/{stepId}/upload_url` + `{ contentType, fileName? }` → `{ objectStorageKey, uploadUrl }`; PUT the file to `uploadUrl`; then + `POST .../steps/{stepId}/documents` `{ objectStorageKey, integrityHash, contentType, fileSizeBytes, originalFileName? }` + → the step moves to `in_review`. Manual steps only (an automated step 400s the upload). +- **The credentials / under-review / rejected states** — render each `step.status` + (`not_started`/`pending`/`in_review`/`passed`/`failed`/`expired`) with the right affordance; `in_review` + = "with our team", `failed`/`expired` = show `failureReason` + a redo path, `passed` = done. +- **The public trust badge (f6)** — `GET api/v1/nurses/{nurseId}/trust_badge` (**anonymous**) → `isVerified`, + `approvedAt?`, `credentialTypes[]`. Types only — never a credential number. Back the nurse-profile trust + chip / verified marker with it. + +## Live endpoints (all under `api/v1`, action-style, camelCase bodies) +Nurse (`[Authorize]`, nurse-scoped): `nurse_verification/submit`, `GET nurse_verification`, +`nurse_verification/steps/{stepId}/upload_url`, `nurse_verification/steps/{stepId}/documents`, +`nurse_verification/steps/identity_kyc/run`, `nurse_verification/steps/shahkar_match/run`, +`nurse_verification/steps/bank_account_verification/run`. +Admin step-types (dynamic-permission): `GET admin_verification_step_types`, `POST admin_verification_step_types`, +`DELETE admin_verification_step_types/{id}`. +Admin review (dynamic-permission): `GET admin_verifications`, `GET admin_verifications/{nurseVerificationId}`, +`admin_verifications/steps/{stepId}/decide`, `admin_verifications/{nurseVerificationId}/suspend`, +`admin_verifications/scan_expiring`. +Public (`[AllowAnonymous]`): `GET nurses/{nurseId}/trust_badge`. + +## Rules baked into the API (don't fight them client-side) +- **`isBookable` / `isVerified` are read-only, server-derived.** Never infer verification client-side — + read `VerificationStatusDto.isBookable` (nurse view) or `TrustBadgeDto.isVerified` (public view). The + aggregate `nurse_verifications.status` is the single source of truth; `is_verified` is flipped only inside + the finalize transaction (and reversed on suspend/expiry). +- **Credential numbers never cross the wire.** `NurseCredentialDto` / `TrustBadgeDto` carry **types** and + metadata only. Don't build a UI that expects to display a number. +- **Automated vs manual steps drive different UI.** `step.isAutomated=true` → a `/run` button; `false` → the + upload flow (`upload_url` → PUT → `documents`). The list tells you which. +- **Ordering / prerequisites** — Shahkar needs identity-KYC passed; bank verification needs KYC **and** a + primary bank account (b3). Calling out of order → **400**; disable/guide accordingly. +- **Shared-SIM and vendor fails are `200` with `stepStatus:"failed"` + `failureReason`**, not HTTP errors — + surface the reason, don't treat as a crash. (Shared-SIM also raises an internal support alert.) +- **Documents come back as short-lived signed URLs** (`VerificationDocumentDto.url`) — fetch/display fresh; + don't cache the URL. +- **Refresh after `select_role`** still applies (nurse scoping reads the role claim in the token). +- **Routes are action-style POST** (`nurse_verification/submit`, `admin_verifications/steps/{id}/decide`, …), + ids from the route, camelCase bodies — see the contract for the full list. + +## Schema / migration +New **`verif`** schema, one additive migration, **5 tables**: `nurse_verifications` (header; `status` = single +source of truth), `verification_step_types` (seeded catalog — six stable codes), `verification_steps` +(one per required step-type; snapshots `is_automated`), `verification_documents` (**metadata only** — bytes +never in the DB), `nurse_credentials` (`credential_number` **encrypted**, never serialized). The only derived +boolean is `nurse_profiles.is_verified`, flipped/reversed transactionally. + +## What's mocked +All vendor/money calls (deterministic seams; test values in the contract): +- `IShahkarVerifier` → `MockShahkarVerifier` (pass unless shared-SIM `09120000000` / mismatch id `1111111111`). +- `IIdentityKycProvider` → `MockIdentityKycProvider` (passes any well-formed 10-digit id except `0000000000`). +- `ICredentialVerifier` → `MockCredentialVerifier` (manual-admin default; `verification_method=manual`). +- **Reused:** `IBankAccountOwnershipVerifier` (b3; mismatch IBAN `IR000000000000000000000000`), + `IObjectStorage` (b0; local-disk signed URLs), `IFieldEncryptor` (b0; encrypts `credential_number`). + +See [`reports/mocks-registry.md`](../../reports/mocks-registry.md) for the make-it-real steps. + +## Deferred to later phases (do not build against these yet) +- **Scheduled expiry cron** (`CredentialExpiryScannerJob`) → the scan logic ships as + `ScanExpiringCredentialsCommand`; today only the admin `scan_expiring` endpoint triggers it. +- **Automated MoH/INO license lookup** (`ICredentialVerifier`, `verification_method=api`) → deferred. +- **`fraud_flags` / ML fraud scoring** → deferred. +- **Professional-liability-insurance step** → addable later as a step-type row (no schema change). +- **b7 (search & matching)** reads `nurse_profiles.is_verified` for `nurse_search_index.is_searchable` — b6 + owns the flip, b7 owns the index (**not built here**). diff --git a/dev/shared-working-context/reports/backend-phase-6-report.md b/dev/shared-working-context/reports/backend-phase-6-report.md new file mode 100644 index 0000000..de2a925 --- /dev/null +++ b/dev/shared-working-context/reports/backend-phase-6-report.md @@ -0,0 +1,126 @@ +# Backend Phase 6 — Nurse verification & credentials (mocked vendors) — report + +**Status:** complete · build clean (0 new code warnings) · `dotnet test Baya.sln` green (**153 pass**). +**Vendors are mocked** (three new DI seams + reused b3/b0 seams) — see [What is mocked](#what-is-mocked). + +## What was built +The trust engine the whole marketplace gates on — a data-driven verification pipeline, the admin review +queue, the structured credential registry, the transactional `nurse_profiles.is_verified` flip, an +admin-triggered credential-expiry scanner, and the public trust badge. One additive migration (new **`verif`** +schema, **5 tables**), **15 endpoints across 4 controllers**, and **3 new mock vendor seams**. + +- **Schema** (new **`verif`** schema): `nurse_verifications` (the header; `status` = the **single source of + verification truth**), `verification_step_types` (the seeded catalog of possible steps), + `verification_steps` (one per required step-type per nurse; snapshots `is_automated`), + `verification_documents` (metadata only — **bytes never in the DB**), `nurse_credentials` + (`credential_number` **encrypted**, never serialized). `nurse_profiles.is_verified` is the **only derived + boolean**, flipped **only inside the finalize transaction**. +- **Six seeded, stable step-type codes:** `identity_kyc`, `shahkar_match`, `moh_competency_license`, + `ino_membership`, `criminal_record`, `bank_account_verification` (three of them credential-bearing). +- **Nurse pipeline** (`NurseVerificationController`, `[Authorize]` + nurse role in handler, tenancy-scoped): + `submit` (upsert header + seed one step per active required step-type; **idempotent**), `GET` status (the + checklist + aggregate + `blockingSteps` + `isBookable`), `upload_url` / `documents` (manual steps → + `in_review`), and three automated `/run` endpoints — `identity_kyc` (populates `users.national_id` + + `national_id_verified_at`), `shahkar_match` (requires KYC; **shared-SIM = explicit handled failure** → + `shared_sim` alert; sets `shahkar_verified_at`), `bank_account_verification` (reuses the b3 + `IBankAccountOwnershipVerifier`; **money-mule guard** — holder national id must equal the verified nurse + national id; on match sets `matched_national_id=1`). +- **Admin step-type catalog** (`AdminVerificationStepTypesController`, dynamic-permission, `sensitive` + rate-limit): cached list (generation token), upsert (snake_case `code`, immutable once in use, dup → + **409**), deactivate (`is_active=false`, **never hard delete**). +- **Admin review** (`AdminVerificationsController`, dynamic-permission, `sensitive` rate-limit): queue + (`in_review` default; docs carry **signed GET URLs**), case detail (steps + docs + credentials + identity + name), `decide` (manual steps; on approving a credential-bearing step records an **encrypted** + `nurse_credentials` row, **holder-name cross-check** vs identity → mismatch **400** with no credential, + `criminal_record` requires `expiresAt`; writes an `audit_logs` record; **re-aggregates**, may flip + `is_verified`), `suspend` (`status=suspended` + **reverse `is_verified=0` in one transaction** + audit), + and `scan_expiring` (reverts lapsed steps → `expired`, `verification_expired` alert + + `verification_expiry_prompt` notification, re-gates bookability). +- **Public trust badge** (`NursesController`, `[AllowAnonymous]`): `GET nurses/{id}/trust_badge` returns + `isVerified` + `approvedAt` + the **credential TYPES held** (never numbers); cached (short TTL, evicted on + suspension/expiry/decision); `404` unknown nurse. +- **The scan logic ships** as `ScanExpiringCredentialsCommand`; the **scheduled cron is deferred** — the + admin `scan_expiring` endpoint is the entry point (config `verification_expiry_scan_cadence_hours`, int, + default `24`). + +## What is now testable — and exactly how (the phase §7 steps) +Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger/curl. +The seam mocks are **deterministic** — use the test national ids / IBAN / phone below. +1. **Open the checklist** — as a nurse (with a b3 nurse profile), `POST /api/v1/nurse_verification/submit` + → `200`; `GET /api/v1/nurse_verification` → `status: "pending"`, `isBookable: false`, one seeded step per + active required step-type, each with its `isAutomated`. +2. **Identity KYC passes** — `POST /api/v1/nurse_verification/steps/identity_kyc/run` + `{ "nationalId": "1234567891" }` → `stepStatus: "passed"`; the nurse's `users.national_id` + + `national_id_verified_at` are populated. The configured fail id `0000000000` → `stepStatus: "failed"` + + `failureReason` (still `200`). +3. **Shahkar match** — `POST /api/v1/nurse_verification/steps/shahkar_match/run` (no body) → `passed`; + `shahkar_verified_at` set. A nurse on the shared-SIM phone `09120000000` → **handled failure** + a + `shared_sim` support alert row. Running it **before** KYC → **400**. +4. **Bank ownership** — with a **primary** `nurse_bank_accounts` row (b3), + `POST /api/v1/nurse_verification/steps/bank_account_verification/run` → on match sets + `matched_national_id=1`; the mismatch IBAN `IR000000000000000000000000` → failed. No primary account → **400**. +5. **Manual document upload** — for a credential step, `POST steps/{stepId}/upload_url` + `{ "contentType": "application/pdf" }` → `{ objectStorageKey, uploadUrl }`; PUT the bytes to `uploadUrl`; + `POST steps/{stepId}/documents` `{ objectStorageKey, integrityHash, contentType, fileSizeBytes }` → the + step moves to `in_review` and a metadata row is stored (no bytes in the DB). An automated step rejects the + upload → **400**. +6. **Admin review queue** — as admin, `GET /api/v1/admin_verifications` (default `status=in_review`) → + the pending step with **signed GET URLs** on its documents; `GET /api/v1/admin_verifications/{id}` → the + full case incl. the identity name for cross-check. +7. **Approve a credential** — `POST /api/v1/admin_verifications/steps/{stepId}/decide` + `{ approve: true, credentialNumber, holderName: "", issuingAuthority, issuedAt }` + → records an **encrypted** credential (`credentialId` returned; the number is never serialized back), + writes an audit record, re-aggregates. A **holder-name mismatch → 400** with **no** credential recorded; + a `criminal_record` approval **without `expiresAt` → 400**; `approve:false` without `rejectionReason` → + **400**. +8. **is_verified flips** — once every required step is `passed`/`approved`, the re-aggregate flips + `nurse_profiles.is_verified=1` **in one transaction**; `GET /api/v1/nurse_verification` now reads + `status: "approved"`, `isBookable: true`. +9. **Public trust badge** — `GET /api/v1/nurses/{nurseId}/trust_badge` → `isVerified: true`, `approvedAt`, + `credentialTypes: ["moh_competency_license", …]` (**types only**, never numbers). Unknown nurse → **404**. +10. **Suspend & expiry** — `POST /api/v1/admin_verifications/{id}/suspend` `{ reason }` sets + `status=suspended` and **reverses `is_verified=0`** in one transaction (+ audit); the trust badge + updates. `POST /api/v1/admin_verifications/scan_expiring` reverts lapsed time-limited steps to `expired`, + raises a `verification_expired` alert + `verification_expiry_prompt` notification, and re-gates + bookability (`ScanExpiringResult{ scannedSteps, revertedNurses }`). + +## What is mocked / waiting on a real service +Three **new** seams (all deterministic, all mocked here) + three **reused** seams: +- `IShahkarVerifier` → `MockShahkarVerifier` — phone↔national-id match: **pass** unless the shared-SIM phone + `09120000000` (→ shared-SIM handled failure) or the mismatch national id `1111111111`. Registry row: + `IShahkarVerifier` in [`mocks-registry.md`](mocks-registry.md). +- `IIdentityKycProvider` → `MockIdentityKycProvider` — national-id + liveness: passes any well-formed 10-digit + national id **except** the configured fail id `0000000000`. Registry row: `IIdentityKycProvider`. +- `ICredentialVerifier` → `MockCredentialVerifier` — MoH/INO/criminal-record: the manual-admin default + (always `RequiresManualReview` / `verification_method=manual`). Registry row: `ICredentialVerifier`. +- **Reused:** `IBankAccountOwnershipVerifier` (b3 — mismatch IBAN `IR000000000000000000000000`), + `IObjectStorage` (b0 — local-disk signed PUT/GET URLs), `IFieldEncryptor` (b0 — encrypts + `credential_number`). + +## Contracts +- **Produced:** [`dev/contracts/domains/verification.md`](../../contracts/domains/verification.md) (all 15 + routes, DTO shapes, the four enums, failure cases, side effects, mock test values, worked example). + `swagger.v1.json` **refreshed** — [`dev/contracts/openapi/swagger.v1.json`](../../contracts/openapi/swagger.v1.json) + includes all 15 b6 endpoints. +- **Consumed:** `nurse_profiles` + `IBankAccountOwnershipVerifier` (b3), `users.national_id` / + `shahkar_verified_at` (b2), `IObjectStorage` / `IFieldEncryptor` / `ICacheService` / CQRS / + `OperationResult` / `BaseController` (b0), dynamic-permission + `support_alerts` / `notifications` / + `audit_logs` + `sensitive` rate-limit (b1). + +## Docs updated +- New contract `dev/contracts/domains/verification.md`; openapi snapshot refreshed. +- Handoff `backend/handoff/after-backend-phase-6.md`; status log `backend/STATUS.md` (append). +- The three b6 seams (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`) are the + pre-listed rows in `reports/mocks-registry.md` — now realized as seam + fake impl. + +## Follow-ups for later phases +- **Scheduled expiry cron** — a `CredentialExpiryScannerJob` hosted scheduler that calls the shipped + `ScanExpiringCredentialsCommand` on `verification_expiry_scan_cadence_hours` cadence. **Deferred** — the + admin `scan_expiring` endpoint is the manual entry point today. +- **Automated MoH/INO license lookup** — behind `ICredentialVerifier` (`verification_method=api`) once a + portal exists. **Deferred.** +- **`fraud_flags` / ML fraud scoring** — **deferred.** +- **Professional-liability-insurance step** — addable later as a `verification_step_types` row (no schema + change). **Deferred.** +- **b7 (search & matching)** — reads `nurse_profiles.is_verified` for `nurse_search_index.is_searchable`; + b6 owns the flip, b7 owns the index. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index 8381fce..3c41b11 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -23,9 +23,9 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 | `IHolidayCalendar` | backend-phase-1 | Bank holidays — reads the seeded `ops.IranianHolidays` table; lookups cached (`HolidayCalendarService`, `Persistence/Services/Holidays/`); Iranian banking weekend = Friday | _none_ | Add a sync job/feed that maintains the (partly lunar-Hijri) calendar table; the read interface stays | 🟡 | | `IAnalyticsSink` | backend-phase-1 | Behavioural events — inserts an `ops.SystemEvents` row, fire-and-forget (`AnalyticsSink`, `Persistence/Services/Analytics/`) | _none_ | Pipe to a warehouse/stream (e.g. Kafka→ClickHouse); keep fire-and-forget semantics | 🟡 | | `IJobScheduler` (retention) | backend-phase-1 | Scheduling — in-process interval `BackgroundService` running `PurgeOldReadNotifications` daily (`NotificationRetentionHostedService`, `Persistence/Services/Notifications/`) | _none_ | Swap to Hangfire/Quartz; register the job there; keep the purge predicate (`is_read=1 AND age>90d`) | 🟡 | -| `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`) | 🔴 | +| `IShahkarVerifier` | backend-phase-6 | شاهکار phone↔national-id binding — `MockShahkarVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic result + fake vendor ref + `external_response_json`: matches every pair except the configured shared-SIM phone (→ the explicit shared-SIM failure state, which the handler turns into a `shared_sim` support alert) and the mismatch national id (→ plain mismatch); registered singleton in `AddCrossCuttingSeams`. No real Shahkar call | `Seams:Shahkar:SharedSimPhone` (default `09120000000`), `Seams:Shahkar:MismatchNationalId` (default `1111111111`) | 1) pick a Finnotech / KYC Shahkar-bridge vendor, add its client package to `Directory.Packages.props`; 2) add `Seams:Shahkar:{ApiKey,BaseUrl}` options; 3) implement `MatchAsync(phone, nationalId)` against the real استعلام شاهکار, mapping to `ShahkarMatchResult` and persisting the raw response into the step's `external_response_json`; 4) keep shared-SIM as the explicit handled failure (`IsSharedSim=true`); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match / shared-SIM / mismatch + that a phone change re-runs it (`shahkar_verified_at` resets upstream on phone change) | 🟡 | +| `IIdentityKycProvider` | backend-phase-6 | Identity KYC (national-id validity + name match + liveness) — `MockIdentityKycProvider` (`.../Seams/`) passes any well-formed 10-digit national id except the configured fail id, returning a matched name + fake vendor ref + `external_response_json`; on pass the handler populates `users.national_id` + `national_id_verified_at`. No real OCR/liveness; registered singleton | `Seams:IdentityKyc:FailNationalId` (default `0000000000`), `Seams:IdentityKyc:MatchedName` (default `Verified Nurse`) | 1) pick an Iranian e-KYC vendor (Finnotech / U-ID / Jibbit / Farashensa / Verify / Kavoshak), add its client package to `Directory.Packages.props`; 2) add `Seams:IdentityKyc:{ApiKey,BaseUrl}` options; 3) implement `VerifyAsync(nationalId, livenessPayload)` → national-id validity + name match + photo/video liveness against ثبت احوال, mapping to `IdentityKycResult` and persisting `external_response_json`; 4) swap the registration (config-selected) — handlers unchanged; 5) test pass/fail by national id + that `national_id` is populated **only** on pass | 🟡 | +| `ICredentialVerifier` | backend-phase-6 | MoH پروانه صلاحیت حرفه‌ای / INO / عدم سوء پیشینه verification — `MockCredentialVerifier` (`.../Seams/`) **is** the manual-admin default: every call returns `RequiresManualReview` with `verification_method=manual` (an admin verifies the uploaded document against the official portal in `AdminReviewStep`). No portal call; registered singleton. There is **no public B2B API** for MoH/INO, so this stays manual until one appears | _none_ | 1) when an MoH/INO portal or API becomes available, implement `VerifyAsync(credentialType, credentialNumber)` to return `Verified`/`Failed` with `verification_method=portal|api` (+ `external_response_json`); 2) swap the registration (config-selected) for those credential types — the manual path stays the fallback; 3) the structured `nurse_credentials` registry already stores number/authority/expiry so cross-check + renewal survive the swap. **MoH/INO have no public B2B API today** | 🟡 | | `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 — `MockGeocoder` (`Baya.Infrastructure.CrossCutting/Seams/`) returns deterministic `decimal` coordinates jittered (FNV-1a, ~±5 km) around the known city centroid (unknown city → Iran centroid) plus `formatted_address` + `confidence`; **no network call**. A global switch or a per-address marker forces the null-coordinate ("no map pin") path; registered singleton in `AddCrossCuttingSeams` | `Seams:Geocoding:ReturnNullCoordinates` (default `false`), `Seams:Geocoding:LowConfidenceMarker` (default `NO_GEO`), `Seams:Geocoding:ResolvedConfidence` (default `0.9`) | 1) pick Neshan (or Google) geocoding, add its client package to `Directory.Packages.props`; 2) add `Seams:Geocoding:{ApiKey,BaseUrl}` options; 3) implement `IGeocoder.GeocodeAsync(addressText, cityName, districtName?)` against it, mapping to `(lat, lng, formatted_address, confidence)` with `decimal` coords; 4) add rate-limit/retry; 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test a known Tehran address resolves within expected bounds | 🟡 | | `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoice — leaves ref pending | _tbd_ | Real مودیان submission → 22-digit ref | 🔴 | @@ -48,3 +48,7 @@ the frontend can build before the backend phase merges, and swap to the real HTT | `ProfilesApi` | `client/src/services/profiles/apis/mockApi.ts` | Customer + nurse profile get/upsert and **avatar upload** (echoes an object-URL). Keeps guarded read-only fields (`isVerified=false`, zero aggregates). Augments customer name/language (REQ-007) + nurse `avatarUrl` (REQ-006) the wire DTOs lack | `USE_PROFILES_MOCK` (`services/profiles/constants.ts`, default `true`) | b3 `customer_profiles/*` + `nurse_profiles/*` are live; deliver REQ-006 (avatar route/field) + REQ-007 (customer name/language) then set flag `false` — `profilesClientApi` is wired (its `uploadAvatar` throws `501` until REQ-006) | 🟡 | | `NurseBankAccountsApi` | `client/src/services/nurse/apis/mockApi.ts` | Bank-account list/add/set-primary/verify-ownership. Drives the استعلام شبا **pending→verified/mismatch** transition over 2 list reads (so the poll shows it), single-primary enforcement, masked-IBAN (last-4); the configured mismatch IBAN (`IR000000000000000000000000`, matches backend default) resolves to `matchedNationalId=false` | `USE_NURSE_BANK_MOCK` (`services/nurse/constants.ts`, default `true`) | b3 `nurse_bank_accounts/*` are live (the real `add` resolves the inquiry synchronously — no client poll needed); set flag `false` — `nurseBankClientApi` is wired | 🟡 | | `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 | +| `GeographyApi` | `client/src/services/geography/apis/mockApi.ts` (+ `apis/seed.ts`) | The province→city→district reference hierarchy — a faithful subset of the b4 seed: 8 provinces, Tehran (city 101) with its 22 مناطق (1001…1022), and the white-space cities Mashhad/Isfahan/Shiraz/Tabriz/Ahvaz/Qom/Karaj as whole-city-only. Active-only, `sortOrder`-ordered. `seed.ts` also resolves a saved `cityId`/`districtId` back to names for the addresses & serviceAreas mocks | `USE_GEOGRAPHY_MOCK` (`services/geography/constants.ts`, default `true`) | b4 `geo/{provinces,cities,districts}` are live; set flag `false` — `geographyClientApi` is wired to the snake_case-param lookups. No hook/component change | 🟡 | +| `AddressesApi` | `client/src/services/addresses/apis/mockApi.ts` | Customer address CRUD (list primary-first / create / update / set-primary / soft-delete) with the **exactly-one-primary** invariant enforced in-memory (first address auto-primary; promoting clears the prior; deleting the primary promotes the next). Persists the client-augmented `provinceId` (REQ-009) and the picked `latitude`/`longitude` (REQ-008) the wire DTO/create-body lack | `USE_ADDRESSES_MOCK` (`services/addresses/constants.ts`, default `true`) | b4 `customer_addresses/*` are live; deliver REQ-008 (accept the pin) + REQ-009 (`provinceId` on the DTO), then set flag `false` — `addressesClientApi` is wired (sends the pin + `pageSize`, echoes `provinceId` locally) | 🟡 | +| `ServiceAreasApi` | `client/src/services/serviceAreas/apis/mockApi.ts` | Nurse coverage areas (list whole-city-first / add / remove). Enforces `UNIQUE(cityId, districtId)` exactly as the server — a duplicate (incl. a second whole-city row) throws the same **`409`** (`area_duplicate`) so the coverage editor's inline dup handling is demonstrable | `USE_SERVICE_AREAS_MOCK` (`services/serviceAreas/constants.ts`, default `true`) | b4 `nurse_service_areas/*` are live; set flag `false` — `serviceAreasClientApi` is wired (maps the server 409 to the same inline message). No hook/component change | 🟡 | +| `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange` — `AddressForm` and every caller stay unchanged | 🟡 | diff --git a/product/business/02-nurse-verification.html b/product/business/02-nurse-verification.html index 548ab22..775a2ef 100644 --- a/product/business/02-nurse-verification.html +++ b/product/business/02-nurse-verification.html @@ -43,6 +43,14 @@
  • MVP: all six steps; data-driven verification_step_types; structured nurse_credentials registry; manual MoH/INO verification; nurse-uploaded عدم سوء پیشینه with expiry; automated identity + Shahkar + IBAN-ownership via one KYC vendor; expiry-driven re-verification alerts; transactional is_verified.
  • DEFERRED: automated MoH/INO license lookup (pending a B2B API); ML-driven fraud scoring (fraud_flags is modeled but inactive); professional-liability-insurance step (addable as a row when required).
  • +

    (c-bis) As-built clarifications (backend-phase-6)

    +

    Rules confirmed while building the pipeline (implementation in the verif schema):

    +
      +
    • Step state pending vs in_review: pending = awaiting submission/automation (a seeded or automated step not yet run); in_review = a manual step with evidence uploaded, awaiting the admin decision. The aggregate is in_review while any required step is in_review, rejected if any required step failed, and approved only when every required step is passed (the same transaction flips is_verified).
    • +
    • Renewal is an in-app notification, not only an alert: an expiry re-scan raises a verification_expired support alert (staff worklist) and sends the nurse a verification_expiry_prompt notification; the required step reverts to expired and bookability is re-gated.
    • +
    • The public trust badge exposes credential _types_ held, never the numbers (e.g. "MoH license · INO member"); credential_number is encrypted and never serialized.
    • +
    • Shared-SIM is surfaced as a distinct shared_sim support-alert type (non-accusatory), separate from a plain phone↔national-id mismatch.
    • +

    (d) Supporting database entities

    nurse_verifications, verification_step_types, verification_steps, verification_documents, nurse_credentials (structured license registry), nurse_bank_accounts (IBAN ownership), support_alerts (expiry/renewal), audit_logs.

    Related: Data model — Verification & Credentials; Research — Verification.

    diff --git a/product/business/02-nurse-verification.md b/product/business/02-nurse-verification.md index d6000f1..d2def6b 100644 --- a/product/business/02-nurse-verification.md +++ b/product/business/02-nurse-verification.md @@ -30,6 +30,13 @@ The verification steps: - **MVP:** all six steps; data-driven `verification_step_types`; structured `nurse_credentials` registry; manual MoH/INO verification; nurse-uploaded عدم سوء پیشینه with expiry; automated identity + Shahkar + IBAN-ownership via one KYC vendor; expiry-driven re-verification alerts; transactional `is_verified`. - **DEFERRED:** automated MoH/INO license lookup (pending a B2B API); ML-driven fraud scoring (`fraud_flags` is modeled but inactive); professional-liability-insurance step (addable as a row when required). +## (c-bis) As-built clarifications (backend-phase-6) +Rules confirmed while building the pipeline (implementation in the `verif` schema): +- **Step state `pending` vs `in_review`:** `pending` = awaiting submission/automation (a seeded or automated step not yet run); `in_review` = a manual step with evidence uploaded, awaiting the admin decision. The aggregate is `in_review` while any required step is `in_review`, `rejected` if any required step failed, and `approved` only when **every** required step is `passed` (the same transaction flips `is_verified`). +- **Renewal is an in-app notification, not only an alert:** an expiry re-scan raises a `verification_expired` **support alert** (staff worklist) *and* sends the nurse a `verification_expiry_prompt` **notification**; the required step reverts to `expired` and bookability is re-gated. +- **The public trust badge exposes credential _types_ held, never the numbers** (e.g. "MoH license · INO member"); `credential_number` is encrypted and never serialized. +- **Shared-SIM** is surfaced as a distinct `shared_sim` support-alert type (non-accusatory), separate from a plain phone↔national-id mismatch. + ## (d) Supporting database entities `nurse_verifications`, `verification_step_types`, `verification_steps`, `verification_documents`, **`nurse_credentials`** (structured license registry), `nurse_bank_accounts` (IBAN ownership), `support_alerts` (expiry/renewal), `audit_logs`. diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 69a450a..0a31730 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -81,15 +81,15 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies. ``` src/ ├── Core/ -│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), + 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; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + the platform-signal facade contracts + Contracts/Persistence per-domain repositories on IUnitOfWork), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly) +│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), + 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; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers) ├── 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 + MockBankAccountOwnershipVerifier) + AddCrossCuttingSeams +│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier) + 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 + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants), appsettings*.json +│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge)), appsettings*.json │ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting) │ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only) ├── Shared/Baya.SharedKernel Extensions + validation base @@ -106,7 +106,8 @@ Application reference Infrastructure or the API — this is a hard rule. **Cross-cutting seams.** Application defines mock-able external dependencies as interfaces in `Contracts/Common/` (`IDateTimeProvider`, `IFieldEncryptor`, `ICacheService`, `IObjectStorage`, -`INotificationDispatcher`, `IGeocoder`, plus `ICurrentUser`). Their in-memory/local mock implementations live in +`INotificationDispatcher`, `IGeocoder`, `IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`, +plus `ICurrentUser`). Their in-memory/local mock implementations live in `Baya.Infrastructure.CrossCutting/Seams/` and are registered by `AddCrossCuttingSeams(configuration)` (config section `Seams`); `ICurrentUser` is registered in the Identity layer. Swapping a mock for a real provider is a registration change — handlers depend only on the contract. Audit fields are diff --git a/server/CONVENTIONS.md b/server/CONVENTIONS.md index 7ba0380..ba64b6f 100644 --- a/server/CONVENTIONS.md +++ b/server/CONVENTIONS.md @@ -312,6 +312,26 @@ collide). Persist it (`NVARCHAR(64)`) and back it with a **filtered unique index with a handler pre-check for the friendly `409`. Reuse this helper for any future "same set of ids" guard; do **not** reuse `IFieldEncryptor.Hash` (that is for PII-column equality lookups). +### Guarded cross-aggregate state flip (backend-phase-6) + +When one write must atomically change a header row's state **and** a derived boolean on a *different* +aggregate (e.g. `nurse_verifications.status` → `nurse_profiles.is_verified`), do it in one transaction: +load **both** as tracked entities, mutate them through a single pure domain helper +(`VerificationAggregator.Finalize`), then `CommitAsync` **once** — never flip the derived flag from a +controller, a partial write, or an out-of-band update, and never leave an in-between state. Two follow-on +rules this establishes: + +- **Self-committing facades come after the atomic commit.** `ISupportAlertService.RaiseAsync`, + `INotificationDispatcher.DispatchAsync`, `IAuditLogger.WriteAsync` and `IPlatformConfig.SetConfig` each call + `SaveChanges` on the *shared scoped* `DbContext`. Calling one mid-build flushes your partial tracked changes — + invoke them only **after** `unitOfWork.CommitAsync()`. In a batch loop that commits per item, load and guard + every dependency **before** mutating tracked state, or an early `continue` leaks a dirty entity that a later + iteration's commit will flush. +- **Persist a status enum as its stable snake_case code, not the member name.** Define the C# enum, then map + it with a `HasConversion(e => e.ToCode(), s => Parse(s))` value converter (see `VerificationCodes`) so the DB + and the wire carry `in_review`, not `InReview`. Enum→code mapping in a projected read happens **in memory + after materialization** (`.ToCode()` is not LINQ-translatable); DTOs expose the code string. + --- ## 7. Validation diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/AdminVerificationStepTypesController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminVerificationStepTypesController.cs new file mode 100644 index 0000000..12f158a --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminVerificationStepTypesController.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Verification.Commands.DeactivateStepType; +using Baya.Application.Features.Verification.Commands.UpsertStepType; +using Baya.Application.Features.Verification.Queries.ListStepTypes; +using Baya.Application.Models.Verification; +using Baya.Infrastructure.Identity.Identity.PermissionManager; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Baya.WebFramework.ServiceConfiguration; + +namespace Baya.Web.Api.Controllers.V1; + +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[Authorize(ConstantPolicies.DynamicPermission)] +[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)] +[Display(Description = "Admin catalog of verification step-types (the data-driven pipeline)")] +public sealed class AdminVerificationStepTypesController(ISender sender) : BaseController +{ + [HttpGet] + [ProducesOkApiResponseType>] + public async Task List([FromQuery] bool includeInactive, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new AdminListStepTypesQuery(includeInactive), cancellationToken)); + + [HttpPost] + [ProducesOkApiResponseType] + public async Task Upsert(AdminUpsertStepTypeCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpDelete("{id}")] + [ProducesOkApiResponseType] + public async Task Deactivate(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new AdminDeactivateStepTypeCommand(id), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/AdminVerificationsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminVerificationsController.cs new file mode 100644 index 0000000..d67f58e --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminVerificationsController.cs @@ -0,0 +1,53 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Verification.Commands.ReviewStep; +using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials; +using Baya.Application.Features.Verification.Commands.SuspendVerification; +using Baya.Application.Features.Verification.Queries.GetVerificationDetail; +using Baya.Application.Features.Verification.Queries.ListPendingSteps; +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Baya.Infrastructure.Identity.Identity.PermissionManager; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Baya.WebFramework.ServiceConfiguration; + +namespace Baya.Web.Api.Controllers.V1; + +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[Authorize(ConstantPolicies.DynamicPermission)] +[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)] +[Display(Description = "Admin verification review queue, decisions, suspension and expiry scan")] +public sealed class AdminVerificationsController(ISender sender) : BaseController +{ + [HttpGet] + [ProducesOkApiResponseType>] + public async Task List([FromQuery] AdminListPendingStepsQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + [HttpGet("{nurseVerificationId}")] + [ProducesOkApiResponseType] + public async Task Get(long nurseVerificationId, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new AdminGetVerificationDetailQuery(nurseVerificationId), cancellationToken)); + + [HttpPost("steps/{stepId}/decide")] + [ProducesOkApiResponseType] + public async Task Decide(long stepId, AdminReviewStepCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { StepId = stepId }, cancellationToken)); + + [HttpPost("{nurseVerificationId}/suspend")] + [ProducesOkApiResponseType] + public async Task Suspend(long nurseVerificationId, AdminSuspendVerificationCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { NurseVerificationId = nurseVerificationId }, cancellationToken)); + + [HttpPost("scan_expiring")] + [ProducesOkApiResponseType] + public async Task ScanExpiring(ScanExpiringCredentialsCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/NurseVerificationController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/NurseVerificationController.cs new file mode 100644 index 0000000..8573472 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/NurseVerificationController.cs @@ -0,0 +1,60 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Verification.Commands.ConfirmDocumentUpload; +using Baya.Application.Features.Verification.Commands.RequestDocumentUploadUrl; +using Baya.Application.Features.Verification.Commands.RunBankAccountVerification; +using Baya.Application.Features.Verification.Commands.RunIdentityKyc; +using Baya.Application.Features.Verification.Commands.RunShahkarMatch; +using Baya.Application.Features.Verification.Commands.SubmitVerification; +using Baya.Application.Features.Verification.Queries.GetStatus; +using Baya.Application.Models.Verification; +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 verification pipeline (checklist, uploads, automated runs)")] +public sealed class NurseVerificationController(ISender sender) : BaseController +{ + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task Submit(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new SubmitNurseVerificationCommand(), cancellationToken)); + + [HttpGet] + [ProducesOkApiResponseType] + public async Task Get(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetNurseVerificationStatusQuery(), cancellationToken)); + + [HttpPost("steps/{stepId}/[action]")] + [ProducesOkApiResponseType] + public async Task UploadUrl(long stepId, RequestDocumentUploadUrlCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { StepId = stepId }, cancellationToken)); + + [HttpPost("steps/{stepId}/documents")] + [ProducesOkApiResponseType] + public async Task ConfirmDocument(long stepId, ConfirmDocumentUploadCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { StepId = stepId }, cancellationToken)); + + [HttpPost("steps/identity_kyc/run")] + [ProducesOkApiResponseType] + public async Task RunIdentityKyc(RunIdentityKycCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("steps/shahkar_match/run")] + [ProducesOkApiResponseType] + public async Task RunShahkarMatch(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new RunShahkarMatchCommand(), cancellationToken)); + + [HttpPost("steps/bank_account_verification/run")] + [ProducesOkApiResponseType] + public async Task RunBankAccountVerification(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new RunBankAccountVerificationCommand(), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/NursesController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/NursesController.cs new file mode 100644 index 0000000..9580783 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/NursesController.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Verification.Queries.GetTrustBadge; +using Baya.Application.Models.Verification; +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]")] +[AllowAnonymous] +[Display(Description = "Public nurse read surface (the verified trust badge)")] +public sealed class NursesController(ISender sender) : BaseController +{ + // Public: the verified badge exposes credential *types* held, never the encrypted numbers. + [HttpGet("{nurseId}/[action]")] + [ProducesOkApiResponseType] + public async Task TrustBadge(long nurseId, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetVerifiedTrustBadgeQuery(nurseId), cancellationToken)); +} diff --git a/server/src/Core/Baya.Application/Common/IdentityNameMatch.cs b/server/src/Core/Baya.Application/Common/IdentityNameMatch.cs new file mode 100644 index 0000000..4ca01f7 --- /dev/null +++ b/server/src/Core/Baya.Application/Common/IdentityNameMatch.cs @@ -0,0 +1,45 @@ +#nullable enable +using System.Globalization; + +namespace Baya.Application.Common; + +/// +/// Cross-checks a credential's printed holder name against the nurse's verified identity name before the +/// credential is recorded — the documented defence against the "imposter nurse" forgery, where a real +/// license belonging to someone else is uploaded. Comparison is normalization-tolerant (ZWNJ, Arabic/Persian +/// yeh & kaf variants, spacing, case) and order-insensitive on name tokens, but still rejects a genuinely +/// different name. An empty identity name fails closed (cannot cross-check ⇒ do not record). +/// +public static class IdentityNameMatch +{ + public static bool Matches(string? identityName, string? holderName) + { + var identityTokens = Tokenize(identityName); + var holderTokens = Tokenize(holderName); + + if (identityTokens.Count == 0 || holderTokens.Count == 0) + return false; + + return identityTokens.SetEquals(holderTokens); + } + + private static HashSet Tokenize(string? value) + { + var set = new HashSet(StringComparer.Ordinal); + if (string.IsNullOrWhiteSpace(value)) + return set; + + foreach (var token in Normalize(value).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + set.Add(token); + + return set; + } + + private static string Normalize(string value) + => value + .Replace('‌', ' ') // ZWNJ → space so "علی‌رضا" and "علی رضا" tokenize the same + .Replace('ي', 'ی') // Arabic yeh → Persian yeh + .Replace('ك', 'ک') // Arabic kaf → Persian kaf + .Trim() + .ToLower(CultureInfo.InvariantCulture); +} diff --git a/server/src/Core/Baya.Application/Common/VerificationAggregator.cs b/server/src/Core/Baya.Application/Common/VerificationAggregator.cs new file mode 100644 index 0000000..975203f --- /dev/null +++ b/server/src/Core/Baya.Application/Common/VerificationAggregator.cs @@ -0,0 +1,77 @@ +#nullable enable +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Verification; + +namespace Baya.Application.Common; + +/// +/// The single place that rolls per-step outcomes into and drives the +/// guarded nurse_profiles.is_verified flip. It mutates the tracked verification + profile +/// in place; the caller commits once, so the status change and the is_verified flip land in the same +/// transaction — there is never an in-between state where the verification is approved but the nurse is not +/// yet bookable (or vice-versa). +/// +/// Rules (steps seeded on submit are exactly the active required steps, so "all steps" == "all required"): +/// approved only when every step passed; rejected if any step failed; in_review if any step awaits an admin; +/// else pending. Suspension is terminal here — an already-suspended verification is not auto-recovered. +/// +public static class VerificationAggregator +{ + public static VerificationStatus Finalize(NurseVerification verification, NurseProfile profile, DateTimeOffset now) + { + // Suspension is an explicit admin state; the scanner/automation must not silently un-suspend a nurse. + if (verification.Status == VerificationStatus.Suspended) + { + profile.MarkUnverified(); + return verification.Status; + } + + var steps = verification.Steps; + var hasSteps = steps.Count > 0; + var anyFailed = steps.Any(s => s.Status == VerificationStepStatus.Failed); + var anyInReview = steps.Any(s => s.Status == VerificationStepStatus.InReview); + var allPassed = hasSteps && steps.All(s => s.Status == VerificationStepStatus.Passed); + + if (allPassed) + { + if (verification.Status != VerificationStatus.Approved) + { + verification.Status = VerificationStatus.Approved; + verification.ApprovedAt = now; + verification.RejectedAt = null; + verification.RejectionReason = null; + } + + profile.MarkVerified(); + return VerificationStatus.Approved; + } + + // Not all required steps passed → the nurse is not bookable. Reverse the flip in the same transaction. + profile.MarkUnverified(); + verification.ApprovedAt = null; + + if (anyFailed) + { + verification.Status = VerificationStatus.Rejected; + verification.RejectedAt = now; + } + else if (anyInReview) + { + verification.Status = VerificationStatus.InReview; + } + else + { + verification.Status = VerificationStatus.Pending; + } + + return verification.Status; + } + + /// The codes of required steps not yet passed — the "what's blocking bookability" summary. + public static IReadOnlyList BlockingStepCodes(IEnumerable steps) + => steps + .Where(s => s.Status != VerificationStepStatus.Passed) + .Select(s => s.StepType?.Code ?? string.Empty) + .Where(c => c.Length > 0) + .ToList(); +} diff --git a/server/src/Core/Baya.Application/Contracts/Common/ICredentialVerifier.cs b/server/src/Core/Baya.Application/Contracts/Common/ICredentialVerifier.cs new file mode 100644 index 0000000..020df4f --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Common/ICredentialVerifier.cs @@ -0,0 +1,32 @@ +#nullable enable +namespace Baya.Application.Contracts.Common; + +/// +/// Seam for verifying a professional credential (MoH پروانه صلاحیت حرفه‌ای, INO membership, +/// عدم سوء پیشینه) against its authoritative source. There is no public B2B API for MoH/INO today, +/// so the default implementation returns +/// (verification_method = manual) — the admin verifies the uploaded document against the official +/// portal. The interface is shaped so an api/portal implementation drops in later without +/// touching callers, keeping the audit-defensible verification_method honest. +/// +public interface ICredentialVerifier +{ + Task VerifyAsync(string credentialType, string? credentialNumber, CancellationToken cancellationToken = default); +} + +/// Whether a credential can be verified automatically or needs a manual admin decision. +public enum CredentialVerificationStatus +{ + RequiresManualReview, + Verified, + Failed +} + +/// Outcome of an check. +/// Manual today; automated once a portal/API is available. +/// The verification_method to record (manual/portal/api). +/// Any raw source response, persisted for audit (null for manual). +public readonly record struct CredentialVerificationResult( + CredentialVerificationStatus Status, + string Method, + string? ExternalResponseJson); diff --git a/server/src/Core/Baya.Application/Contracts/Common/IIdentityKycProvider.cs b/server/src/Core/Baya.Application/Contracts/Common/IIdentityKycProvider.cs new file mode 100644 index 0000000..30ef8a1 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Common/IIdentityKycProvider.cs @@ -0,0 +1,27 @@ +#nullable enable +namespace Baya.Application.Contracts.Common; + +/// +/// Seam for the identity KYC vendor — national-id validity + name match + photo/video liveness against the +/// civil-registry (ثبت احوال) record. On pass it yields the matched name used to populate the verified +/// identity and cross-check later credentials. Buy this, don't build it: the mock returns a deterministic +/// pass/fail keyed off a test national-id; the real implementation swaps in an Iranian e-KYC vendor +/// (Finnotech / U-ID / Jibbit / Farashensa / Verify / Kavoshak) by a registration change only. +/// +public interface IIdentityKycProvider +{ + Task VerifyAsync(string nationalId, string? livenessPayload, CancellationToken cancellationToken = default); +} + +/// Outcome of an verification. +/// Whether identity + liveness passed. +/// The full name the vendor matched (for the credential cross-check), when passed. +/// The vendor transaction id, kept for audit. +/// The raw vendor response blob, persisted for audit. +/// A reason when is false. +public readonly record struct IdentityKycResult( + bool Passed, + string? MatchedName, + string VendorRef, + string ExternalResponseJson, + string? FailureReason); diff --git a/server/src/Core/Baya.Application/Contracts/Common/IShahkarVerifier.cs b/server/src/Core/Baya.Application/Contracts/Common/IShahkarVerifier.cs new file mode 100644 index 0000000..14f1cce --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Common/IShahkarVerifier.cs @@ -0,0 +1,27 @@ +#nullable enable +namespace Baya.Application.Contracts.Common; + +/// +/// Seam for the Shahkar (شاهکار) phone↔national-id binding inquiry — confirms the login SIM is registered +/// to the nurse's own national id. The shared-SIM failure mode (a SIM owned by a family member) is an +/// explicit, handled state, never an undefined edge. The mock returns a deterministic result keyed off a +/// test phone/national-id; the real implementation calls a Finnotech/KYC vendor. No real Shahkar call and +/// no money moves through this seam. +/// +public interface IShahkarVerifier +{ + Task MatchAsync(string? phoneNumber, string? nationalId, CancellationToken cancellationToken = default); +} + +/// Outcome of a inquiry. +/// Whether the SIM is bound to the given national id. +/// The explicit shared-SIM failure state (raises a support alert). +/// The vendor transaction id, kept for audit. +/// The raw vendor response blob, persisted for audit. +/// A non-accusatory reason when is false. +public readonly record struct ShahkarMatchResult( + bool Matched, + bool IsSharedSim, + string VendorRef, + string ExternalResponseJson, + string? FailureReason); diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/INurseBankAccountRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/INurseBankAccountRepository.cs index 3afa7eb..9fd2124 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/INurseBankAccountRepository.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/INurseBankAccountRepository.cs @@ -26,6 +26,10 @@ public interface INurseBankAccountRepository /// primary). Task HasAnyAsync(long nurseId, CancellationToken cancellationToken); + /// Tracked primary account for the nurse (the payout destination the verification bank-ownership + /// step re-checks) — null if the nurse has no primary account yet. + Task GetPrimaryAsync(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 index 926e759..38de340 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/INurseProfileRepository.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/INurseProfileRepository.cs @@ -9,6 +9,10 @@ public interface INurseProfileRepository /// Tracked lookup of the nurse's profile by owning user id (for upsert/toggle). Task GetByUserIdAsync(int userId, CancellationToken cancellationToken); + /// Tracked lookup of the nurse's profile by its own id — the verification finalize/suspend + /// transaction loads it to flip the guarded is_verified flag in the same commit. + Task GetTrackedByIdAsync(long nurseProfileId, CancellationToken cancellationToken); + Task AddAsync(NurseProfile profile, CancellationToken cancellationToken); /// No-tracking projection of the signed-in nurse's profile, incl. read-only verified flag diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs index 7c7098d..61bb44c 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs @@ -14,6 +14,7 @@ public interface IUnitOfWork public ICustomerAddressRepository CustomerAddressRepository { get; } public ICatalogRepository CatalogRepository { get; } public INurseServiceVariantRepository NurseServiceVariantRepository { get; } + public IVerificationRepository VerificationRepository { get; } Task CommitAsync(); ValueTask RollBackAsync(); } diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IVerificationRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IVerificationRepository.cs new file mode 100644 index 0000000..eba4101 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IVerificationRepository.cs @@ -0,0 +1,61 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Baya.Domain.Entities.Verification; + +namespace Baya.Application.Contracts.Persistence; + +/// +/// Persistence for the verification pipeline. Tracked getters (for the state-changing handlers) load the +/// aggregate with its steps so the finalize transaction can flip +/// nurse_profiles.is_verified in one SaveChanges. Read getters are projected + paginated; +/// document-bearing reads take a so signed GET URLs are minted without the +/// repository depending on the storage seam. +/// +public interface IVerificationRepository +{ + // --- Step-type catalog --- + Task GetStepTypeByIdAsync(long id, CancellationToken cancellationToken); + Task StepTypeCodeExistsAsync(string code, CancellationToken cancellationToken); + Task StepTypeInUseAsync(long stepTypeId, CancellationToken cancellationToken); + Task AddStepTypeAsync(VerificationStepType stepType, CancellationToken cancellationToken); + Task> ListStepTypesAsync(bool includeInactive, CancellationToken cancellationToken); + Task> GetActiveRequiredStepTypesAsync(CancellationToken cancellationToken); + + // --- Nurse verification aggregate (tracked, for mutation) --- + Task GetTrackedByNurseIdAsync(long nurseId, CancellationToken cancellationToken); + Task GetTrackedByIdAsync(long nurseVerificationId, CancellationToken cancellationToken); + Task AddVerificationAsync(NurseVerification verification, CancellationToken cancellationToken); + + /// Tracked step scoped to the nurse (tenancy) with its step-type loaded; null if not owned. + Task GetTrackedStepForNurseAsync(long stepId, long nurseId, CancellationToken cancellationToken); + + /// Tracked step with its parent verification (and all sibling steps) + step-type, for an admin + /// decision that must re-aggregate the whole verification. + Task GetTrackedStepWithVerificationAsync(long stepId, CancellationToken cancellationToken); + + // --- Documents / credentials --- + Task AddDocumentAsync(VerificationDocument document, CancellationToken cancellationToken); + Task AddCredentialAsync(NurseCredential credential, CancellationToken cancellationToken); + + // --- Projected reads --- + Task GetStatusForNurseAsync(long nurseId, CancellationToken cancellationToken); + Task> ListPendingStepsAsync( + VerificationStepStatus? status, int page, int pageSize, Func signUrl, CancellationToken cancellationToken); + Task GetDetailAsync(long nurseVerificationId, Func signUrl, CancellationToken cancellationToken); + Task GetTrustBadgeAsync(long nurseId, DateOnly today, CancellationToken cancellationToken); + + /// The nurse's verified identity name ("Name FamilyName") for the credential holder cross-check. + Task GetNurseIdentityNameAsync(long nurseId, CancellationToken cancellationToken); + + /// Tracked users row for the signed-in nurse — the identity-KYC step populates + /// national_id + national_id_verified_at and Shahkar sets shahkar_verified_at on it. + Task GetTrackedUserAsync(int userId, CancellationToken cancellationToken); + + /// Passed, time-limited steps whose expiry has lapsed — the expiry-scan worklist (paginated). + Task> GetExpiredPassedStepsAsync( + DateTimeOffset asOf, int page, int pageSize, CancellationToken cancellationToken); +} + +/// An expired, previously-passed step that the scanner must revert + re-gate. +public readonly record struct ExpiringStepRow(long StepId, long NurseVerificationId, long NurseId); diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/ConfirmDocumentUpload/ConfirmDocumentUploadCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/ConfirmDocumentUpload/ConfirmDocumentUploadCommand.Handler.cs new file mode 100644 index 0000000..44159e2 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/ConfirmDocumentUpload/ConfirmDocumentUploadCommand.Handler.cs @@ -0,0 +1,68 @@ +#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.Verification; +using Baya.Domain.Entities.User; +using Baya.Domain.Entities.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.ConfirmDocumentUpload; + +internal sealed class ConfirmDocumentUploadCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle( + ConfirmDocumentUploadCommand 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 upload verification documents."); + + var context = await unitOfWork.NurseProfileRepository.GetIdentityContextByUserIdAsync(userId, cancellationToken); + if (context is null) + return OperationResult.NotFoundResult("No nurse profile exists yet."); + + var nurseId = context.NurseProfileId; + var step = await unitOfWork.VerificationRepository.GetTrackedStepForNurseAsync(request.StepId, nurseId, cancellationToken); + if (step is null) + return OperationResult.NotFoundResult("Verification step not found."); + + if (step.IsAutomated) + return OperationResult.FailureResult("This step is verified automatically and does not accept document uploads."); + + var now = dateTimeProvider.UtcNow; + + var document = new VerificationDocument + { + StepId = step.Id, + ObjectStorageKey = request.ObjectStorageKey, + IntegrityHash = request.IntegrityHash, + ContentType = request.ContentType, + FileSizeBytes = request.FileSizeBytes, + OriginalFileName = request.OriginalFileName, + UploadedByUserId = userId + }; + await unitOfWork.VerificationRepository.AddDocumentAsync(document, cancellationToken); + + // A manual step with evidence attached moves into the admin review queue. + step.Status = VerificationStepStatus.InReview; + step.StartedAt ??= now; + + var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(nurseId, cancellationToken); + if (profile is null) + return OperationResult.NotFoundResult("Nurse profile not found."); + + VerificationAggregator.Finalize(step.NurseVerification, profile, now); + + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(new DocumentConfirmedResult(document.Id, step.Status.ToCode())); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/ConfirmDocumentUpload/ConfirmDocumentUploadCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/ConfirmDocumentUpload/ConfirmDocumentUploadCommand.Validator.cs new file mode 100644 index 0000000..a8ec59f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/ConfirmDocumentUpload/ConfirmDocumentUploadCommand.Validator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace Baya.Application.Features.Verification.Commands.ConfirmDocumentUpload; + +// StepId is route-supplied (set via `command with { StepId = ... }`), so it is not validated here. +public sealed class ConfirmDocumentUploadCommandValidator : AbstractValidator +{ + public ConfirmDocumentUploadCommandValidator() + { + RuleFor(x => x.ObjectStorageKey).NotEmpty().MaximumLength(400); + RuleFor(x => x.IntegrityHash).NotEmpty().MaximumLength(64); + RuleFor(x => x.ContentType).NotEmpty().MaximumLength(100); + RuleFor(x => x.FileSizeBytes).GreaterThan(0); + RuleFor(x => x.OriginalFileName).MaximumLength(260); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/ConfirmDocumentUpload/ConfirmDocumentUploadCommand.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/ConfirmDocumentUpload/ConfirmDocumentUploadCommand.cs new file mode 100644 index 0000000..a5e8053 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/ConfirmDocumentUpload/ConfirmDocumentUploadCommand.cs @@ -0,0 +1,18 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.ConfirmDocumentUpload; + +/// +/// Persists the metadata row for a document the client already uploaded to the signed URL — bytes never +/// enter the DB. Moves a manual step to in_review. StepId is route-supplied. +/// +public record ConfirmDocumentUploadCommand( + long StepId, + string ObjectStorageKey, + string IntegrityHash, + string ContentType, + long FileSizeBytes, + string? OriginalFileName) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/DeactivateStepType/AdminDeactivateStepTypeCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/DeactivateStepType/AdminDeactivateStepTypeCommand.Handler.cs new file mode 100644 index 0000000..7e6385f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/DeactivateStepType/AdminDeactivateStepTypeCommand.Handler.cs @@ -0,0 +1,24 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.DeactivateStepType; + +internal sealed class AdminDeactivateStepTypeCommandHandler(IUnitOfWork unitOfWork, ICacheService cache) + : IRequestHandler> +{ + public async ValueTask> Handle(AdminDeactivateStepTypeCommand request, CancellationToken cancellationToken) + { + var type = await unitOfWork.VerificationRepository.GetStepTypeByIdAsync(request.Id, cancellationToken); + if (type is null) + return OperationResult.NotFoundResult("Step type not found."); + + type.IsActive = false; + await unitOfWork.CommitAsync(); + await VerificationCache.InvalidateStepTypesAsync(cache, cancellationToken); + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/DeactivateStepType/AdminDeactivateStepTypeCommand.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/DeactivateStepType/AdminDeactivateStepTypeCommand.cs new file mode 100644 index 0000000..8a325d5 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/DeactivateStepType/AdminDeactivateStepTypeCommand.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.DeactivateStepType; + +/// Deactivates a step-type (sets is_active = false) — never a hard delete, so historical +/// verifications that seeded a step from it keep their meaning. +public record AdminDeactivateStepTypeCommand(long Id) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/RequestDocumentUploadUrl/RequestDocumentUploadUrlCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/RequestDocumentUploadUrl/RequestDocumentUploadUrlCommand.Handler.cs new file mode 100644 index 0000000..6c53e6a --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/RequestDocumentUploadUrl/RequestDocumentUploadUrlCommand.Handler.cs @@ -0,0 +1,43 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Baya.Domain.Entities.User; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.RequestDocumentUploadUrl; + +internal sealed class RequestDocumentUploadUrlCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IObjectStorage objectStorage) + : IRequestHandler> +{ + public async ValueTask> Handle( + RequestDocumentUploadUrlCommand 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 upload verification documents."); + + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is not { } id) + return OperationResult.NotFoundResult("No nurse profile exists yet."); + + var step = await unitOfWork.VerificationRepository.GetTrackedStepForNurseAsync(request.StepId, id, cancellationToken); + if (step is null) + return OperationResult.NotFoundResult("Verification step not found."); + + if (step.IsAutomated) + return OperationResult.FailureResult("This step is verified automatically and does not accept document uploads."); + + // Opaque, tenant-scoped key; the mock stores on local disk, a real provider presigns a PUT URL. + var key = $"verification/{id}/{step.Id}/{Guid.NewGuid():N}"; + var uploadUrl = objectStorage.GetUrl(key); + + return OperationResult.SuccessResult(new UploadUrlResult(key, uploadUrl)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/RequestDocumentUploadUrl/RequestDocumentUploadUrlCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/RequestDocumentUploadUrl/RequestDocumentUploadUrlCommand.Validator.cs new file mode 100644 index 0000000..30a9d9a --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/RequestDocumentUploadUrl/RequestDocumentUploadUrlCommand.Validator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace Baya.Application.Features.Verification.Commands.RequestDocumentUploadUrl; + +// StepId is route-supplied (set via `command with { StepId = ... }`), so it is not validated here. +public sealed class RequestDocumentUploadUrlCommandValidator : AbstractValidator +{ + public RequestDocumentUploadUrlCommandValidator() + { + RuleFor(x => x.ContentType).NotEmpty().MaximumLength(100); + RuleFor(x => x.FileName).MaximumLength(260); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/RequestDocumentUploadUrl/RequestDocumentUploadUrlCommand.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/RequestDocumentUploadUrl/RequestDocumentUploadUrlCommand.cs new file mode 100644 index 0000000..ef77f67 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/RequestDocumentUploadUrl/RequestDocumentUploadUrlCommand.cs @@ -0,0 +1,11 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.RequestDocumentUploadUrl; + +/// Returns a signed PUT URL for a manual-evidence upload on the nurse's own step. StepId is +/// route-supplied. +public record RequestDocumentUploadUrlCommand(long StepId, string ContentType, string? FileName) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/ReviewStep/AdminReviewStepCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/ReviewStep/AdminReviewStepCommand.Handler.cs new file mode 100644 index 0000000..ad216ec --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/ReviewStep/AdminReviewStepCommand.Handler.cs @@ -0,0 +1,144 @@ +#nullable enable +using Baya.Application.Common; +using Baya.Application.Contracts.Audit; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Baya.Domain.Entities.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.ReviewStep; + +internal sealed class AdminReviewStepCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + ICredentialVerifier credentialVerifier, + IAuditLogger auditLogger, + ICacheService cache, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(AdminReviewStepCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } adminId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var repo = unitOfWork.VerificationRepository; + + var step = await repo.GetTrackedStepWithVerificationAsync(request.StepId, cancellationToken); + if (step is null) + return OperationResult.NotFoundResult("Verification step not found."); + + if (step.IsAutomated) + return OperationResult.FailureResult("Automated steps are decided by their vendor run, not manual review."); + + var verification = step.NurseVerification; + var nurseId = verification.NurseId; + var now = dateTimeProvider.UtcNow; + NurseCredential? recordedCredential = null; + + if (request.Approve) + { + if (VerificationStepTypeCodes.CredentialBearing.Contains(step.StepType.Code)) + { + var credentialResult = await RecordCredentialAsync(request, step, nurseId, adminId, repo, cancellationToken); + if (!credentialResult.IsSuccess) + return OperationResult.FailureResult(FirstError(credentialResult)); + + recordedCredential = credentialResult.Result; + } + + step.Status = VerificationStepStatus.Passed; + step.FailureReason = null; + step.CompletedAt = now; + } + else + { + step.Status = VerificationStepStatus.Failed; + step.FailureReason = request.RejectionReason; + step.CompletedAt = now; + verification.RejectionReason = request.RejectionReason; + } + + verification.ReviewedByAdminId = adminId; + + var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(nurseId, cancellationToken); + if (profile is null) + return OperationResult.NotFoundResult("Nurse profile not found."); + + VerificationAggregator.Finalize(verification, profile, now); + + // The step decision, the recorded credential, and any is_verified flip land in one transaction. + await unitOfWork.CommitAsync(); + + await auditLogger.WriteAsync( + "verification_step", + step.Id.ToString(), + request.Approve ? "approve" : "reject", + new Dictionary + { + ["step_code"] = step.StepType.Code, + ["decision"] = request.Approve ? "passed" : "failed", + ["admin_id"] = adminId, + ["reason"] = request.Approve ? null : request.RejectionReason + }, + cancellationToken); + + await VerificationCache.InvalidateBadgeAsync(cache, nurseId, cancellationToken); + + return OperationResult.SuccessResult(new ReviewStepResult(step.Id, step.Status.ToCode(), recordedCredential?.Id)); + } + + private async ValueTask> RecordCredentialAsync( + AdminReviewStepCommand request, + VerificationStep step, + long nurseId, + int adminId, + IVerificationRepository repo, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.CredentialNumber) + || string.IsNullOrWhiteSpace(request.HolderName) + || string.IsNullOrWhiteSpace(request.IssuingAuthority)) + { + return OperationResult.FailureResult("Credential number, holder name and issuing authority are required to approve this step."); + } + + // The criminal-record certificate is time-limited — an expiry is mandatory and drives the re-scan. + if (step.StepType.Code == VerificationStepTypeCodes.CriminalRecord && request.ExpiresAt is null) + return OperationResult.FailureResult("An expiry date is required for the criminal-record certificate."); + + // Anti-forgery gate: the printed holder name must match the verified identity before we record it. + var identityName = await repo.GetNurseIdentityNameAsync(nurseId, cancellationToken); + if (!IdentityNameMatch.Matches(identityName, request.HolderName)) + return OperationResult.FailureResult("The credential holder name does not match the verified identity — it cannot be recorded."); + + var check = await credentialVerifier.VerifyAsync(step.StepType.Code, request.CredentialNumber, cancellationToken); + + var credential = new NurseCredential + { + NurseId = nurseId, + CredentialType = step.StepType.Code, + CredentialNumber = request.CredentialNumber!.Trim(), + HolderNameSnapshot = request.HolderName!.Trim(), + IssuingAuthority = request.IssuingAuthority!.Trim(), + IssuedAt = request.IssuedAt, + ExpiresAt = request.ExpiresAt, + VerificationSource = request.VerificationSource, + VerificationMethod = check.Method, + VerifiedByAdminId = adminId + }; + await repo.AddCredentialAsync(credential, cancellationToken); + + // Mirror the credential expiry onto the (time-limited) step so the scanner can re-gate on it. Valid + // through the whole expiry day → the step lapses at the start of the following day. + if (request.ExpiresAt is { } expires) + step.ExpiresAt = new DateTimeOffset(expires.AddDays(1).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); + + return OperationResult.SuccessResult(credential); + } + + private static string FirstError(IOperationResult result) + => result.ErrorMessages.Count > 0 ? result.ErrorMessages[0].Value : "Credential could not be recorded."; +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/ReviewStep/AdminReviewStepCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/ReviewStep/AdminReviewStepCommand.Validator.cs new file mode 100644 index 0000000..5793f50 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/ReviewStep/AdminReviewStepCommand.Validator.cs @@ -0,0 +1,22 @@ +using FluentValidation; + +namespace Baya.Application.Features.Verification.Commands.ReviewStep; + +// StepId is route-supplied. Credential-field requirements that depend on the step's code (e.g. criminal +// record requires expires_at) are enforced in the handler, where the step is known. +public sealed class AdminReviewStepCommandValidator : AbstractValidator +{ + public AdminReviewStepCommandValidator() + { + RuleFor(x => x.RejectionReason) + .NotEmpty() + .MaximumLength(1000) + .When(x => !x.Approve) + .WithMessage("A rejection reason is required when rejecting a step."); + + RuleFor(x => x.CredentialNumber).MaximumLength(100); + RuleFor(x => x.HolderName).MaximumLength(200); + RuleFor(x => x.IssuingAuthority).MaximumLength(200); + RuleFor(x => x.VerificationSource).MaximumLength(300); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/ReviewStep/AdminReviewStepCommand.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/ReviewStep/AdminReviewStepCommand.cs new file mode 100644 index 0000000..c9ee730 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/ReviewStep/AdminReviewStepCommand.cs @@ -0,0 +1,23 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.ReviewStep; + +/// +/// Admin manual decision on a step. Approve=false requires a RejectionReason. Approving a +/// credential-bearing step (MoH / INO / criminal-record) records a nurse_credentials row — the +/// encrypted number plus a holder name that must cross-check against the verified identity. StepId +/// is route-supplied. +/// +public record AdminReviewStepCommand( + long StepId, + bool Approve, + string? RejectionReason, + string? CredentialNumber, + string? HolderName, + string? IssuingAuthority, + DateOnly? IssuedAt, + DateOnly? ExpiresAt, + string? VerificationSource) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/RunBankAccountVerification/RunBankAccountVerificationCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/RunBankAccountVerification/RunBankAccountVerificationCommand.Handler.cs new file mode 100644 index 0000000..a85dc84 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/RunBankAccountVerification/RunBankAccountVerificationCommand.Handler.cs @@ -0,0 +1,84 @@ +#nullable enable +using System.Text.Json; +using Baya.Application.Common; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Baya.Domain.Entities.User; +using Baya.Domain.Entities.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.RunBankAccountVerification; + +internal sealed class RunBankAccountVerificationCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IBankAccountOwnershipVerifier ownershipVerifier, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(RunBankAccountVerificationCommand 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 verification."); + + var repo = unitOfWork.VerificationRepository; + + var context = await unitOfWork.NurseProfileRepository.GetIdentityContextByUserIdAsync(userId, cancellationToken); + if (context is null) + return OperationResult.NotFoundResult("No nurse profile exists yet."); + + if (string.IsNullOrEmpty(context.NationalId)) + return OperationResult.FailureResult("Complete identity verification (KYC) before running the bank-account check."); + + var nurseId = context.NurseProfileId; + + var verification = await repo.GetTrackedByNurseIdAsync(nurseId, cancellationToken); + var step = verification?.Steps.FirstOrDefault(s => s.StepType.Code == VerificationStepTypeCodes.BankAccountVerification); + if (verification is null || step is null) + return OperationResult.NotFoundResult("Submit your verification first — the bank-account step is not in your checklist."); + + var account = await unitOfWork.NurseBankAccountRepository.GetPrimaryAsync(nurseId, cancellationToken); + if (account is null) + return OperationResult.FailureResult("Add a primary payout account before running the bank-account check."); + + var now = dateTimeProvider.UtcNow; + step.StartedAt ??= now; + + var inquiry = await ownershipVerifier.VerifyOwnershipAsync(account.Iban, context.NationalId, cancellationToken); + account.ApplyOwnershipInquiry(inquiry.MatchedNationalId, inquiry.AccountHolderFromBank, inquiry.VendorRef); + + step.ExternalResponseJson = JsonSerializer.Serialize(new + { + provider = "mock_sheba", + matched = inquiry.MatchedNationalId, + vendor_ref = inquiry.VendorRef + }); + + if (inquiry.MatchedNationalId) + { + step.Status = VerificationStepStatus.Passed; + step.FailureReason = null; + step.CompletedAt = now; + } + else + { + step.Status = VerificationStepStatus.Failed; + step.FailureReason = "The payout account holder's national ID does not match your verified national ID."; + step.CompletedAt = now; + } + + var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(nurseId, cancellationToken); + if (profile is null) + return OperationResult.NotFoundResult("Nurse profile not found."); + + VerificationAggregator.Finalize(verification, profile, now); + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(new RunStepResult(step.Id, step.Status.ToCode(), step.FailureReason)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/RunBankAccountVerification/RunBankAccountVerificationCommand.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/RunBankAccountVerification/RunBankAccountVerificationCommand.cs new file mode 100644 index 0000000..9d30eac --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/RunBankAccountVerification/RunBankAccountVerificationCommand.cs @@ -0,0 +1,10 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.RunBankAccountVerification; + +/// Runs the automated IBAN-ownership (استعلام شبا) step on the nurse's primary payout account — +/// the holder national id must equal the verified nurse national id (money-mule guard). Requires identity +/// KYC to have passed and a primary account to exist. +public record RunBankAccountVerificationCommand : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/RunIdentityKyc/RunIdentityKycCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/RunIdentityKyc/RunIdentityKycCommand.Handler.cs new file mode 100644 index 0000000..74996b1 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/RunIdentityKyc/RunIdentityKycCommand.Handler.cs @@ -0,0 +1,75 @@ +#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.Verification; +using Baya.Domain.Entities.User; +using Baya.Domain.Entities.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.RunIdentityKyc; + +internal sealed class RunIdentityKycCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IIdentityKycProvider identityKyc, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(RunIdentityKycCommand 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 verification."); + + var repo = unitOfWork.VerificationRepository; + + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is not { } id) + return OperationResult.NotFoundResult("No nurse profile exists yet."); + + var verification = await repo.GetTrackedByNurseIdAsync(id, cancellationToken); + var step = verification?.Steps.FirstOrDefault(s => s.StepType.Code == VerificationStepTypeCodes.IdentityKyc); + if (verification is null || step is null) + return OperationResult.NotFoundResult("Submit your verification first — the identity step is not in your checklist."); + + var now = dateTimeProvider.UtcNow; + step.StartedAt ??= now; + + var kyc = await identityKyc.VerifyAsync(request.NationalId, request.LivenessPayload, cancellationToken); + step.ExternalResponseJson = kyc.ExternalResponseJson; + + if (kyc.Passed) + { + var user = await repo.GetTrackedUserAsync(userId, cancellationToken); + if (user is null) + return OperationResult.NotFoundResult("User not found."); + + // national_id is populated only after this step passes — every downstream comparison uses it. + user.NationalId = request.NationalId; + user.NationalIdVerifiedAt = now; + + step.Status = VerificationStepStatus.Passed; + step.FailureReason = null; + step.CompletedAt = now; + } + else + { + step.Status = VerificationStepStatus.Failed; + step.FailureReason = kyc.FailureReason; + step.CompletedAt = now; + } + + var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(id, cancellationToken); + if (profile is null) + return OperationResult.NotFoundResult("Nurse profile not found."); + + VerificationAggregator.Finalize(verification, profile, now); + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(new RunStepResult(step.Id, step.Status.ToCode(), step.FailureReason)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/RunIdentityKyc/RunIdentityKycCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/RunIdentityKyc/RunIdentityKycCommand.Validator.cs new file mode 100644 index 0000000..d47842d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/RunIdentityKyc/RunIdentityKycCommand.Validator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Baya.Application.Features.Verification.Commands.RunIdentityKyc; + +public sealed class RunIdentityKycCommandValidator : AbstractValidator +{ + public RunIdentityKycCommandValidator() + { + RuleFor(x => x.NationalId) + .NotEmpty() + .Matches(@"^\d{10}$") + .WithMessage("National ID must be exactly 10 digits."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/RunIdentityKyc/RunIdentityKycCommand.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/RunIdentityKyc/RunIdentityKycCommand.cs new file mode 100644 index 0000000..aaadd2f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/RunIdentityKyc/RunIdentityKycCommand.cs @@ -0,0 +1,12 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.RunIdentityKyc; + +/// Runs the automated identity-KYC step for the signed-in nurse. On pass it populates the verified +/// users.national_id — the anchor every downstream comparison (Shahkar, IBAN, credential cross-check) +/// uses. +public record RunIdentityKycCommand(string NationalId, string? LivenessPayload) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/RunShahkarMatch/RunShahkarMatchCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/RunShahkarMatch/RunShahkarMatchCommand.Handler.cs new file mode 100644 index 0000000..32a7366 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/RunShahkarMatch/RunShahkarMatchCommand.Handler.cs @@ -0,0 +1,87 @@ +#nullable enable +using Baya.Application.Common; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.SupportAlerts; +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Baya.Domain.Entities.SupportAlerts; +using Baya.Domain.Entities.User; +using Baya.Domain.Entities.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.RunShahkarMatch; + +internal sealed class RunShahkarMatchCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IShahkarVerifier shahkarVerifier, + ISupportAlertService supportAlerts, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(RunShahkarMatchCommand 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 verification."); + + var repo = unitOfWork.VerificationRepository; + + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is not { } id) + return OperationResult.NotFoundResult("No nurse profile exists yet."); + + var verification = await repo.GetTrackedByNurseIdAsync(id, cancellationToken); + var step = verification?.Steps.FirstOrDefault(s => s.StepType.Code == VerificationStepTypeCodes.ShahkarMatch); + if (verification is null || step is null) + return OperationResult.NotFoundResult("Submit your verification first — the Shahkar step is not in your checklist."); + + var user = await repo.GetTrackedUserAsync(userId, cancellationToken); + if (user is null) + return OperationResult.NotFoundResult("User not found."); + + if (string.IsNullOrEmpty(user.NationalId)) + return OperationResult.FailureResult("Complete identity verification (KYC) before running the Shahkar check."); + + var now = dateTimeProvider.UtcNow; + step.StartedAt ??= now; + + var match = await shahkarVerifier.MatchAsync(user.PhoneNumber, user.NationalId, cancellationToken); + step.ExternalResponseJson = match.ExternalResponseJson; + + if (match.Matched) + { + user.ShahkarVerifiedAt = now; + step.Status = VerificationStepStatus.Passed; + step.FailureReason = null; + step.CompletedAt = now; + } + else + { + step.Status = VerificationStepStatus.Failed; + step.FailureReason = match.FailureReason; + step.CompletedAt = now; + } + + var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(id, cancellationToken); + if (profile is null) + return OperationResult.NotFoundResult("Nurse profile not found."); + + VerificationAggregator.Finalize(verification, profile, now); + await unitOfWork.CommitAsync(); + + // Shared-SIM is a distinct, non-accusatory handled state — flag it for staff follow-up. Raised + // after the atomic step write (RaiseAsync self-commits its own alert row). + if (match is { Matched: false, IsSharedSim: true }) + { + await supportAlerts.RaiseAsync( + SupportAlertType.SharedSim, "nurse_profile", id.ToString(), SupportAlertSeverity.High, + cancellationToken: cancellationToken); + } + + return OperationResult.SuccessResult(new RunStepResult(step.Id, step.Status.ToCode(), step.FailureReason)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/RunShahkarMatch/RunShahkarMatchCommand.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/RunShahkarMatch/RunShahkarMatchCommand.cs new file mode 100644 index 0000000..3b1e3c2 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/RunShahkarMatch/RunShahkarMatchCommand.cs @@ -0,0 +1,10 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.RunShahkarMatch; + +/// Runs the automated Shahkar phone↔national-id binding for the signed-in nurse. Requires identity +/// KYC to have passed (national id present). Shared-SIM is an explicit handled failure that raises a support +/// alert. +public record RunShahkarMatchCommand : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/ScanExpiringCredentials/ScanExpiringCredentialsCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/ScanExpiringCredentials/ScanExpiringCredentialsCommand.Handler.cs new file mode 100644 index 0000000..3cdbbc0 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/ScanExpiringCredentials/ScanExpiringCredentialsCommand.Handler.cs @@ -0,0 +1,86 @@ +#nullable enable +using Baya.Application.Common; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.SupportAlerts; +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Baya.Domain.Entities.SupportAlerts; +using Baya.Domain.Entities.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.ScanExpiringCredentials; + +internal sealed class ScanExpiringCredentialsCommandHandler( + IUnitOfWork unitOfWork, + ISupportAlertService supportAlerts, + INotificationDispatcher notifications, + ICacheService cache, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(ScanExpiringCredentialsCommand request, CancellationToken cancellationToken) + { + var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize); + var now = dateTimeProvider.UtcNow; + var repo = unitOfWork.VerificationRepository; + + var expired = await repo.GetExpiredPassedStepsAsync(now, page, pageSize, cancellationToken); + var scannedSteps = expired.Count; + var revertedNurses = 0; + + foreach (var group in expired.GroupBy(e => e.NurseVerificationId)) + { + var verification = await repo.GetTrackedByIdAsync(group.Key, cancellationToken); + if (verification is null) + continue; + + var nurseId = verification.NurseId; + + // Load and guard the tracked profile BEFORE mutating any step: an early `continue` must never + // leave a dirty (Expired) step in the shared scoped DbContext for a later nurse's commit to + // flush — that would persist the step-expiry without its atomic is_verified re-gate. + var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(nurseId, cancellationToken); + if (profile is null) + continue; + + var revertedAny = false; + foreach (var row in group) + { + var step = verification.Steps.FirstOrDefault(s => s.Id == row.StepId); + if (step is { Status: VerificationStepStatus.Passed, ExpiresAt: not null } && step.ExpiresAt < now) + { + step.Status = VerificationStepStatus.Expired; + step.FailureReason = "Credential expired; please re-upload a current certificate."; + step.CompletedAt = now; + revertedAny = true; + } + } + + if (!revertedAny) + continue; + + // A lapsed required credential must never silently keep a nurse verified — re-gate atomically. + VerificationAggregator.Finalize(verification, profile, now); + await unitOfWork.CommitAsync(); + revertedNurses++; + + // Side effects (each self-commits its own row): staff worklist + renewal prompt + badge eviction. + await supportAlerts.RaiseAsync( + SupportAlertType.VerificationExpired, "nurse_profile", nurseId.ToString(), SupportAlertSeverity.Medium, + cancellationToken: cancellationToken); + + await notifications.DispatchAsync( + new Notification( + profile.UserId, + "verification_expiry_prompt", + "A verification credential has expired", + "A required credential has expired. Please renew it to remain bookable."), + cancellationToken); + + await VerificationCache.InvalidateBadgeAsync(cache, nurseId, cancellationToken); + } + + return OperationResult.SuccessResult(new ScanExpiringResult(scannedSteps, revertedNurses)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/ScanExpiringCredentials/ScanExpiringCredentialsCommand.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/ScanExpiringCredentials/ScanExpiringCredentialsCommand.cs new file mode 100644 index 0000000..76c43ba --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/ScanExpiringCredentials/ScanExpiringCredentialsCommand.cs @@ -0,0 +1,11 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.ScanExpiringCredentials; + +/// Admin-triggered scan for lapsed time-limited steps (criminal-record especially). Reverts each +/// to expired, raises a support alert + renewal notification, and re-gates bookability. The scheduled cron +/// is deferred — this is the clean entry point it will call. Batched/paginated. +public record ScanExpiringCredentialsCommand(int Page = 1, int PageSize = 50) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/SubmitVerification/SubmitNurseVerificationCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/SubmitVerification/SubmitNurseVerificationCommand.Handler.cs new file mode 100644 index 0000000..a859ff2 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/SubmitVerification/SubmitNurseVerificationCommand.Handler.cs @@ -0,0 +1,75 @@ +#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.Verification; +using Baya.Domain.Entities.User; +using Baya.Domain.Entities.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.SubmitVerification; + +internal sealed class SubmitNurseVerificationCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle( + SubmitNurseVerificationCommand 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 submit a verification."); + + var repo = unitOfWork.VerificationRepository; + + var context = await unitOfWork.NurseProfileRepository.GetIdentityContextByUserIdAsync(userId, cancellationToken); + if (context is null) + return OperationResult.FailureResult("No nurse profile exists yet. Create your profile first."); + + var nurseId = context.NurseProfileId; + var now = dateTimeProvider.UtcNow; + + var verification = await repo.GetTrackedByNurseIdAsync(nurseId, cancellationToken); + if (verification is null) + { + verification = new NurseVerification { NurseId = nurseId, Status = VerificationStatus.Pending, SubmittedAt = now }; + await repo.AddVerificationAsync(verification, cancellationToken); + } + else if (verification.SubmittedAt is null) + { + verification.SubmittedAt = now; + } + + // Seed one step per active *required* step-type, snapshotting is_automated. Idempotent: only the + // step-types not already seeded are added, so re-submitting never duplicates a step. + var requiredTypes = await repo.GetActiveRequiredStepTypesAsync(cancellationToken); + var existingTypeIds = verification.Steps.Select(s => s.StepTypeId).ToHashSet(); + + foreach (var stepType in requiredTypes.Where(t => !existingTypeIds.Contains(t.Id))) + { + verification.Steps.Add(new VerificationStep + { + NurseVerification = verification, + StepTypeId = stepType.Id, + Status = VerificationStepStatus.Pending, + IsAutomated = stepType.IsAutomated + }); + } + + var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(nurseId, cancellationToken); + if (profile is null) + return OperationResult.NotFoundResult("Nurse profile not found."); + + VerificationAggregator.Finalize(verification, profile, now); + + await unitOfWork.CommitAsync(); + + var status = await repo.GetStatusForNurseAsync(nurseId, cancellationToken); + return OperationResult.SuccessResult(status!); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/SubmitVerification/SubmitNurseVerificationCommand.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/SubmitVerification/SubmitNurseVerificationCommand.cs new file mode 100644 index 0000000..05f4f45 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/SubmitVerification/SubmitNurseVerificationCommand.cs @@ -0,0 +1,12 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.SubmitVerification; + +/// +/// Starts (or re-syncs) the signed-in nurse's verification: upserts the header and seeds one step per active +/// required step-type, snapshotting each step-type's automation flag. Idempotent — re-submitting never +/// duplicates a step and only adds steps for newly-required step-types. +/// +public record SubmitNurseVerificationCommand : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/SuspendVerification/AdminSuspendVerificationCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/SuspendVerification/AdminSuspendVerificationCommand.Handler.cs new file mode 100644 index 0000000..72a162e --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/SuspendVerification/AdminSuspendVerificationCommand.Handler.cs @@ -0,0 +1,58 @@ +#nullable enable +using Baya.Application.Common; +using Baya.Application.Contracts.Audit; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.SuspendVerification; + +internal sealed class AdminSuspendVerificationCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IAuditLogger auditLogger, + ICacheService cache, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(AdminSuspendVerificationCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } adminId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var verification = await unitOfWork.VerificationRepository.GetTrackedByIdAsync(request.NurseVerificationId, cancellationToken); + if (verification is null) + return OperationResult.NotFoundResult("Verification not found."); + + var now = dateTimeProvider.UtcNow; + var nurseId = verification.NurseId; + + verification.Status = VerificationStatus.Suspended; + verification.SuspendedAt = now; + verification.ReviewedByAdminId = adminId; + verification.InternalNotes = request.Reason; + + var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(nurseId, cancellationToken); + if (profile is null) + return OperationResult.NotFoundResult("Nurse profile not found."); + + // Suspended status → the aggregator reverses is_verified in the same transaction. + VerificationAggregator.Finalize(verification, profile, now); + + await unitOfWork.CommitAsync(); + + await auditLogger.WriteAsync( + "nurse_verification", + verification.Id.ToString(), + "suspend", + new Dictionary { ["admin_id"] = adminId, ["reason"] = request.Reason }, + cancellationToken); + + // Safety-critical: a suspended nurse must not read as verified — evict the badge immediately. + await VerificationCache.InvalidateBadgeAsync(cache, nurseId, cancellationToken); + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/SuspendVerification/AdminSuspendVerificationCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/SuspendVerification/AdminSuspendVerificationCommand.Validator.cs new file mode 100644 index 0000000..d6432f7 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/SuspendVerification/AdminSuspendVerificationCommand.Validator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Baya.Application.Features.Verification.Commands.SuspendVerification; + +// NurseVerificationId is route-supplied (set via `command with { ... }`), so it is not validated here. +public sealed class AdminSuspendVerificationCommandValidator : AbstractValidator +{ + public AdminSuspendVerificationCommandValidator() + { + RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/SuspendVerification/AdminSuspendVerificationCommand.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/SuspendVerification/AdminSuspendVerificationCommand.cs new file mode 100644 index 0000000..c407305 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/SuspendVerification/AdminSuspendVerificationCommand.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.SuspendVerification; + +/// Suspends a nurse's verification and reverses the is_verified flip in the same +/// transaction (un-publishing the nurse from search). NurseVerificationId is route-supplied. +public record AdminSuspendVerificationCommand(long NurseVerificationId, string Reason) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/UpsertStepType/AdminUpsertStepTypeCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/UpsertStepType/AdminUpsertStepTypeCommand.Handler.cs new file mode 100644 index 0000000..9f9cbcd --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/UpsertStepType/AdminUpsertStepTypeCommand.Handler.cs @@ -0,0 +1,74 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Baya.Domain.Entities.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.UpsertStepType; + +internal sealed class AdminUpsertStepTypeCommandHandler(IUnitOfWork unitOfWork, ICacheService cache) + : IRequestHandler> +{ + public async ValueTask> Handle( + AdminUpsertStepTypeCommand request, CancellationToken cancellationToken) + { + var repo = unitOfWork.VerificationRepository; + VerificationStepType type; + + if (request.Id is { } id) + { + var existing = await repo.GetStepTypeByIdAsync(id, cancellationToken); + if (existing is null) + return OperationResult.NotFoundResult("Step type not found."); + + if (!string.Equals(existing.Code, request.Code, StringComparison.Ordinal)) + { + // The stable machine code is what steps snapshot against — it must not change once any + // nurse verification already seeded a step from this type. + if (await repo.StepTypeInUseAsync(id, cancellationToken)) + return OperationResult.FailureResult(nameof(request.Code), "Code cannot change once the step type is in use."); + + if (await repo.StepTypeCodeExistsAsync(request.Code, cancellationToken)) + return OperationResult.ConflictResult("A step type with this code already exists."); + + existing.Code = request.Code; + } + + existing.DisplayName = request.DisplayName; + existing.Description = request.Description; + existing.IsRequired = request.IsRequired; + existing.IsAutomated = request.IsAutomated; + existing.AutomationProvider = request.AutomationProvider; + existing.SortOrder = request.SortOrder; + existing.IsActive = request.IsActive; + type = existing; + } + else + { + if (await repo.StepTypeCodeExistsAsync(request.Code, cancellationToken)) + return OperationResult.ConflictResult("A step type with this code already exists."); + + type = new VerificationStepType + { + Code = request.Code, + DisplayName = request.DisplayName, + Description = request.Description, + IsRequired = request.IsRequired, + IsAutomated = request.IsAutomated, + AutomationProvider = request.AutomationProvider, + SortOrder = request.SortOrder, + IsActive = request.IsActive + }; + await repo.AddStepTypeAsync(type, cancellationToken); + } + + await unitOfWork.CommitAsync(); + await VerificationCache.InvalidateStepTypesAsync(cache, cancellationToken); + + return OperationResult.SuccessResult(new VerificationStepTypeDto( + type.Id, type.Code, type.DisplayName, type.Description, type.IsRequired, type.IsAutomated, + type.AutomationProvider, type.SortOrder, type.IsActive)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/UpsertStepType/AdminUpsertStepTypeCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/UpsertStepType/AdminUpsertStepTypeCommand.Validator.cs new file mode 100644 index 0000000..93a19a9 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/UpsertStepType/AdminUpsertStepTypeCommand.Validator.cs @@ -0,0 +1,20 @@ +using FluentValidation; + +namespace Baya.Application.Features.Verification.Commands.UpsertStepType; + +public sealed class AdminUpsertStepTypeCommandValidator : AbstractValidator +{ + public AdminUpsertStepTypeCommandValidator() + { + RuleFor(x => x.Code) + .NotEmpty() + .MaximumLength(50) + .Matches("^[a-z][a-z0-9_]*$") + .WithMessage("Code must be a stable snake_case machine key (a–z, 0–9, underscore)."); + + RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(150); + RuleFor(x => x.Description).MaximumLength(500); + RuleFor(x => x.AutomationProvider).MaximumLength(50); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/UpsertStepType/AdminUpsertStepTypeCommand.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/UpsertStepType/AdminUpsertStepTypeCommand.cs new file mode 100644 index 0000000..02e8904 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/UpsertStepType/AdminUpsertStepTypeCommand.cs @@ -0,0 +1,22 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.UpsertStepType; + +/// +/// Admin create/update of a pipeline step-type. Adding a step is one row (the pipeline is data-driven). +/// null creates; otherwise updates. Code is immutable once the step-type is in +/// use by a nurse verification. +/// +public record AdminUpsertStepTypeCommand( + long? Id, + string Code, + string DisplayName, + string? Description, + bool IsRequired, + bool IsAutomated, + string? AutomationProvider, + int SortOrder, + bool IsActive) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Verification/Queries/GetStatus/GetNurseVerificationStatusQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Queries/GetStatus/GetNurseVerificationStatusQuery.Handler.cs new file mode 100644 index 0000000..042ea79 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Queries/GetStatus/GetNurseVerificationStatusQuery.Handler.cs @@ -0,0 +1,35 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Baya.Domain.Entities.User; +using Baya.Domain.Entities.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Queries.GetStatus; + +internal sealed class GetNurseVerificationStatusQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle( + GetNurseVerificationStatusQuery 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 read their verification."); + + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is not { } id) + return OperationResult.NotFoundResult("No nurse profile exists yet."); + + var status = await unitOfWork.VerificationRepository.GetStatusForNurseAsync(id, cancellationToken); + + // No verification row yet — return the empty "not started" checklist so the client can prompt submit. + status ??= new VerificationStatusDto(VerificationStatus.NotStarted.ToCode(), false, [], []); + + return OperationResult.SuccessResult(status); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Queries/GetStatus/GetNurseVerificationStatusQuery.cs b/server/src/Core/Baya.Application/Features/Verification/Queries/GetStatus/GetNurseVerificationStatusQuery.cs new file mode 100644 index 0000000..bb74b09 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Queries/GetStatus/GetNurseVerificationStatusQuery.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Queries.GetStatus; + +/// The signed-in nurse's verification checklist + aggregate status + "what's blocking bookability". +public record GetNurseVerificationStatusQuery : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Verification/Queries/GetTrustBadge/GetVerifiedTrustBadgeQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Queries/GetTrustBadge/GetVerifiedTrustBadgeQuery.Handler.cs new file mode 100644 index 0000000..ded70d8 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Queries/GetTrustBadge/GetVerifiedTrustBadgeQuery.Handler.cs @@ -0,0 +1,33 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Queries.GetTrustBadge; + +internal sealed class GetVerifiedTrustBadgeQueryHandler( + IUnitOfWork unitOfWork, + ICacheService cache, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(GetVerifiedTrustBadgeQuery request, CancellationToken cancellationToken) + { + var key = VerificationCache.BadgeKey(request.NurseId); + + var cached = await cache.GetAsync(key, cancellationToken); + if (cached is not null) + return OperationResult.SuccessResult(cached); + + var today = DateOnly.FromDateTime(dateTimeProvider.UtcNow.UtcDateTime); + var badge = await unitOfWork.VerificationRepository.GetTrustBadgeAsync(request.NurseId, today, cancellationToken); + if (badge is null) + return OperationResult.NotFoundResult("Nurse not found."); + + // Only found badges are cached; a suspension/expiry evicts this key so it is never stale-verified. + await cache.SetAsync(key, badge, VerificationCache.BadgeTtl, cancellationToken); + return OperationResult.SuccessResult(badge); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Queries/GetTrustBadge/GetVerifiedTrustBadgeQuery.cs b/server/src/Core/Baya.Application/Features/Verification/Queries/GetTrustBadge/GetVerifiedTrustBadgeQuery.cs new file mode 100644 index 0000000..f3af346 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Queries/GetTrustBadge/GetVerifiedTrustBadgeQuery.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Queries.GetTrustBadge; + +/// The public "verified" trust badge for a nurse — verified status + credential types held +/// (never the numbers). Cached. +public record GetVerifiedTrustBadgeQuery(long NurseId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Verification/Queries/GetVerificationDetail/AdminGetVerificationDetailQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Queries/GetVerificationDetail/AdminGetVerificationDetailQuery.Handler.cs new file mode 100644 index 0000000..00d855e --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Queries/GetVerificationDetail/AdminGetVerificationDetailQuery.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.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Queries.GetVerificationDetail; + +internal sealed class AdminGetVerificationDetailQueryHandler(IUnitOfWork unitOfWork, IObjectStorage objectStorage) + : IRequestHandler> +{ + public async ValueTask> Handle( + AdminGetVerificationDetailQuery request, CancellationToken cancellationToken) + { + var detail = await unitOfWork.VerificationRepository.GetDetailAsync( + request.NurseVerificationId, key => objectStorage.GetUrl(key), cancellationToken); + + return detail is null + ? OperationResult.NotFoundResult("Verification not found.") + : OperationResult.SuccessResult(detail); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Queries/GetVerificationDetail/AdminGetVerificationDetailQuery.cs b/server/src/Core/Baya.Application/Features/Verification/Queries/GetVerificationDetail/AdminGetVerificationDetailQuery.cs new file mode 100644 index 0000000..f9fa0da --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Queries/GetVerificationDetail/AdminGetVerificationDetailQuery.cs @@ -0,0 +1,10 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Queries.GetVerificationDetail; + +/// Full per-nurse verification detail for the admin doc-viewer — all steps, documents (signed +/// URLs), existing credentials, and the identity name for the holder cross-check. +public record AdminGetVerificationDetailQuery(long NurseVerificationId) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Verification/Queries/ListPendingSteps/AdminListPendingStepsQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Queries/ListPendingSteps/AdminListPendingStepsQuery.Handler.cs new file mode 100644 index 0000000..330cb30 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Queries/ListPendingSteps/AdminListPendingStepsQuery.Handler.cs @@ -0,0 +1,26 @@ +#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.Verification; +using Baya.Domain.Entities.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Queries.ListPendingSteps; + +internal sealed class AdminListPendingStepsQueryHandler(IUnitOfWork unitOfWork, IObjectStorage objectStorage) + : IRequestHandler>> +{ + public async ValueTask>> Handle( + AdminListPendingStepsQuery request, CancellationToken cancellationToken) + { + var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize); + var status = VerificationCodes.TryParseStepStatus(request.Status); + + var result = await unitOfWork.VerificationRepository.ListPendingStepsAsync( + status, page, pageSize, key => objectStorage.GetUrl(key), cancellationToken); + + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Queries/ListPendingSteps/AdminListPendingStepsQuery.cs b/server/src/Core/Baya.Application/Features/Verification/Queries/ListPendingSteps/AdminListPendingStepsQuery.cs new file mode 100644 index 0000000..88f7405 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Queries/ListPendingSteps/AdminListPendingStepsQuery.cs @@ -0,0 +1,11 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Queries.ListPendingSteps; + +/// The admin manual-review worklist — steps in the given status (default in_review) with +/// their submitted documents (signed GET URLs). Projected + paginated. +public record AdminListPendingStepsQuery(string? Status = null, int Page = 1, int PageSize = 50) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Verification/Queries/ListStepTypes/AdminListStepTypesQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Queries/ListStepTypes/AdminListStepTypesQuery.Handler.cs new file mode 100644 index 0000000..4ba6ff0 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Queries/ListStepTypes/AdminListStepTypesQuery.Handler.cs @@ -0,0 +1,26 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Queries.ListStepTypes; + +internal sealed class AdminListStepTypesQueryHandler(IUnitOfWork unitOfWork, ICacheService cache) + : IRequestHandler>> +{ + public async ValueTask>> Handle( + AdminListStepTypesQuery request, CancellationToken cancellationToken) + { + var version = await VerificationCache.VersionAsync(cache, cancellationToken); + + var result = await cache.GetOrCreateAsync( + VerificationCache.StepTypesKey(version, request.IncludeInactive), + async ct => await unitOfWork.VerificationRepository.ListStepTypesAsync(request.IncludeInactive, ct), + VerificationCache.Ttl, + cancellationToken); + + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Queries/ListStepTypes/AdminListStepTypesQuery.cs b/server/src/Core/Baya.Application/Features/Verification/Queries/ListStepTypes/AdminListStepTypesQuery.cs new file mode 100644 index 0000000..b7e4588 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Queries/ListStepTypes/AdminListStepTypesQuery.cs @@ -0,0 +1,10 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Queries.ListStepTypes; + +/// Admin catalog of pipeline step-types (read-heavy reference data, cached). Set +/// to include deactivated rows. +public record AdminListStepTypesQuery(bool IncludeInactive = false) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Verification/VerificationCache.cs b/server/src/Core/Baya.Application/Features/Verification/VerificationCache.cs new file mode 100644 index 0000000..d7ce194 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/VerificationCache.cs @@ -0,0 +1,37 @@ +#nullable enable +using Baya.Application.Contracts.Common; + +namespace Baya.Application.Features.Verification; + +/// +/// Cache-key scheme for verification reads. The step-type catalog is read-heavy reference data behind a +/// generation token (any admin step-type write bumps it, orphaning the whole namespace in one move — the +/// geo/catalog pattern). The public trust badge is cached per nurse with a short TTL and is explicitly +/// evicted on any state change that must reflect immediately (suspension, expiry re-gate, an admin decision). +/// +internal static class VerificationCache +{ + private const string VersionKey = "verification:step_types:version"; + + public static readonly TimeSpan Ttl = TimeSpan.FromHours(1); + + /// Short TTL: a suspended/expired nurse is also evicted explicitly, so the badge is never + /// stale-verified for long. + public static readonly TimeSpan BadgeTtl = TimeSpan.FromSeconds(60); + + public static ValueTask VersionAsync(ICacheService cache, CancellationToken cancellationToken) + => cache.GetOrCreateAsync(VersionKey, _ => ValueTask.FromResult(NewToken()), null, cancellationToken); + + public static ValueTask InvalidateStepTypesAsync(ICacheService cache, CancellationToken cancellationToken) + => cache.SetAsync(VersionKey, NewToken(), null, cancellationToken); + + public static string StepTypesKey(string version, bool includeInactive) + => $"verification:{version}:step_types:{includeInactive}"; + + public static string BadgeKey(long nurseId) => $"verification:badge:{nurseId}"; + + public static ValueTask InvalidateBadgeAsync(ICacheService cache, long nurseId, CancellationToken cancellationToken) + => cache.RemoveAsync(BadgeKey(nurseId), cancellationToken); + + private static string NewToken() => Guid.NewGuid().ToString("N"); +} diff --git a/server/src/Core/Baya.Application/Models/Verification/AdminVerificationDtos.cs b/server/src/Core/Baya.Application/Models/Verification/AdminVerificationDtos.cs new file mode 100644 index 0000000..836ce3f --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Verification/AdminVerificationDtos.cs @@ -0,0 +1,36 @@ +#nullable enable +namespace Baya.Application.Models.Verification; + +/// A row in the admin manual-review worklist — one pending step with its submitted documents +/// (signed GET URLs). +public record AdminPendingStepDto( + long NurseVerificationId, + long NurseId, + string NurseName, + long StepId, + string StepCode, + string StepDisplayName, + string Status, + DateTimeOffset? SubmittedAt, + IReadOnlyList Documents); + +/// A step inside the admin per-nurse detail view. +public record AdminStepDetailDto( + long StepId, + string Code, + string DisplayName, + string Status, + bool IsAutomated, + DateTimeOffset? ExpiresAt, + string? FailureReason, + IReadOnlyList Documents); + +/// Full per-nurse verification detail for the admin doc-viewer, incl. the identity name for the +/// holder-name cross-check and the credentials already on file. +public record AdminVerificationDetailDto( + long NurseVerificationId, + long NurseId, + string IdentityName, + string Status, + IReadOnlyList Steps, + IReadOnlyList Credentials); diff --git a/server/src/Core/Baya.Application/Models/Verification/VerificationDtos.cs b/server/src/Core/Baya.Application/Models/Verification/VerificationDtos.cs new file mode 100644 index 0000000..6745018 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Verification/VerificationDtos.cs @@ -0,0 +1,60 @@ +#nullable enable +namespace Baya.Application.Models.Verification; + +/// An admin step-type catalog row. +public record VerificationStepTypeDto( + long Id, + string Code, + string DisplayName, + string? Description, + bool IsRequired, + bool IsAutomated, + string? AutomationProvider, + int SortOrder, + bool IsActive); + +/// One step in the nurse's checklist. Status is a snake_case code. +public record VerificationStepDto( + long Id, + string Code, + string DisplayName, + string Status, + bool IsAutomated, + DateTimeOffset? ExpiresAt, + string? FailureReason); + +/// +/// The nurse's aggregate verification state + per-step checklist. IsBookable mirrors +/// nurse_profiles.is_verified; BlockingSteps lists the codes of required steps not yet passed +/// (what the nurse must still complete to become bookable). +/// +public record VerificationStatusDto( + string Status, + bool IsBookable, + IReadOnlyList BlockingSteps, + IReadOnlyList Steps); + +/// Uploaded-evidence metadata + a short-lived signed URL. Bytes never touch the DB. +public record VerificationDocumentDto( + long Id, + string ContentType, + long FileSizeBytes, + string? OriginalFileName, + string Url); + +/// A structured credential — the number is never serialized. +public record NurseCredentialDto( + long Id, + string CredentialType, + string HolderNameSnapshot, + string IssuingAuthority, + DateOnly? IssuedAt, + DateOnly? ExpiresAt, + string VerificationMethod); + +/// The public "verified" badge — credential types held, never the numbers. +public record TrustBadgeDto( + long NurseId, + bool IsVerified, + DateTimeOffset? ApprovedAt, + IReadOnlyList CredentialTypes); diff --git a/server/src/Core/Baya.Application/Models/Verification/VerificationResults.cs b/server/src/Core/Baya.Application/Models/Verification/VerificationResults.cs new file mode 100644 index 0000000..1eda4bb --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Verification/VerificationResults.cs @@ -0,0 +1,17 @@ +#nullable enable +namespace Baya.Application.Models.Verification; + +/// The signed PUT URL for a manual-evidence upload + the storage key the client echoes on confirm. +public record UploadUrlResult(string ObjectStorageKey, string UploadUrl); + +/// The persisted document-metadata row + the step's resulting status. +public record DocumentConfirmedResult(long DocumentId, string StepStatus); + +/// The outcome of an automated step run (identity KYC / Shahkar / bank ownership). +public record RunStepResult(long StepId, string StepStatus, string? FailureReason); + +/// The outcome of an admin manual decision on a step. +public record ReviewStepResult(long StepId, string StepStatus, long? CredentialId); + +/// The result of an admin-triggered expiry scan. +public record ScanExpiringResult(int ScannedSteps, int RevertedNurses); diff --git a/server/src/Core/Baya.Domain/Entities/SupportAlerts/SupportAlert.cs b/server/src/Core/Baya.Domain/Entities/SupportAlerts/SupportAlert.cs index da4ef7e..e1712de 100644 --- a/server/src/Core/Baya.Domain/Entities/SupportAlerts/SupportAlert.cs +++ b/server/src/Core/Baya.Domain/Entities/SupportAlerts/SupportAlert.cs @@ -41,12 +41,13 @@ public static class SupportAlertType public const string EvvNoShow = "evv_no_show"; public const string EvvLocationMismatch = "evv_location_mismatch"; public const string VerificationExpired = "verification_expired"; + public const string SharedSim = "shared_sim"; public const string PaymentAnomaly = "payment_anomaly"; public const string FraudSignal = "fraud_signal"; public static readonly IReadOnlyList All = [ - LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, PaymentAnomaly, FraudSignal + LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, SharedSim, PaymentAnomaly, FraudSignal ]; } diff --git a/server/src/Core/Baya.Domain/Entities/Verification/NurseCredential.cs b/server/src/Core/Baya.Domain/Entities/Verification/NurseCredential.cs new file mode 100644 index 0000000..6717b8a --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Verification/NurseCredential.cs @@ -0,0 +1,42 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Verification; + +/// +/// The structured, queryable credential registry — the actual license/membership numbers, authority, +/// holder-name-as-printed and issue/expiry dates behind the opaque uploads. Powers the public trust badge +/// (types held, never numbers), renewal/expiry alerts and cross-checking. is +/// encrypted PII (through IFieldEncryptor); is cross-checked against +/// the nurse's verified identity name before the credential is recorded — never trust an uploaded file alone. +/// +public class NurseCredential : BaseEntity +{ + public long NurseId { get; set; } + + /// One of . + public string CredentialType { get; set; } = string.Empty; + + /// Encrypted at rest — never serialized on the wire. + public string CredentialNumber { get; set; } = string.Empty; + + /// Name as printed on the credential, snapshotted for the identity cross-check. + public string HolderNameSnapshot { get; set; } = string.Empty; + + public string IssuingAuthority { get; set; } = string.Empty; + + public DateOnly? IssuedAt { get; set; } + + /// Drives renewal alerts and the expiry scanner re-gate. + public DateOnly? ExpiresAt { get; set; } + + /// Portal URL / method used to verify (audit defensibility). + public string? VerificationSource { get; set; } + + /// One of (manual today for MoH/INO/criminal). + public string VerificationMethod { get; set; } = VerificationMethods.Manual; + + public int? VerifiedByAdminId { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Verification/NurseVerification.cs b/server/src/Core/Baya.Domain/Entities/Verification/NurseVerification.cs new file mode 100644 index 0000000..3d7f53c --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Verification/NurseVerification.cs @@ -0,0 +1,39 @@ +#nullable enable +using Baya.Domain.Common; +using Baya.Domain.Entities.Identity; + +namespace Baya.Domain.Entities.Verification; + +/// +/// The master per-nurse verification record and the single source of verification truth. Its +/// rolls up the per-step outcomes; the derived nurse_profiles.is_verified +/// boolean is flipped only inside the finalize transaction when every required step has passed (and +/// reversed on suspension). The legacy nurse_profiles.verification_status column was deliberately +/// cut — never reintroduce a second copy of this state. +/// +public class NurseVerification : BaseEntity +{ + public long NurseId { get; set; } + + /// Read-projection navigation (nurse name / identity for the admin queue). The finalize/suspend + /// flip loads the tracked profile separately, so this is not the write path for is_verified. + public NurseProfile Nurse { get; set; } = null!; + + public VerificationStatus Status { get; set; } = VerificationStatus.NotStarted; + + public DateTimeOffset? SubmittedAt { get; set; } + public DateTimeOffset? ApprovedAt { get; set; } + public DateTimeOffset? RejectedAt { get; set; } + public DateTimeOffset? SuspendedAt { get; set; } + + public string? RejectionReason { get; set; } + + /// The admin who last drove a manual decision (review/suspend). + public int? ReviewedByAdminId { get; set; } + + public string? InternalNotes { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public ICollection Steps { get; set; } = new List(); +} diff --git a/server/src/Core/Baya.Domain/Entities/Verification/VerificationCodes.cs b/server/src/Core/Baya.Domain/Entities/Verification/VerificationCodes.cs new file mode 100644 index 0000000..0440693 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Verification/VerificationCodes.cs @@ -0,0 +1,66 @@ +#nullable enable +namespace Baya.Domain.Entities.Verification; + +/// +/// Maps the verification status enums to/from the stable snake_case codes stored in the DB and sent on the +/// wire. Kept explicit (no reflection) so the persisted/contract vocabulary is greppable and can never +/// drift from an accidental enum rename. +/// +public static class VerificationCodes +{ + public static string ToCode(this VerificationStatus status) => status switch + { + VerificationStatus.NotStarted => "not_started", + VerificationStatus.Pending => "pending", + VerificationStatus.InReview => "in_review", + VerificationStatus.Approved => "approved", + VerificationStatus.Rejected => "rejected", + VerificationStatus.Suspended => "suspended", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null) + }; + + public static string ToCode(this VerificationStepStatus status) => status switch + { + VerificationStepStatus.NotStarted => "not_started", + VerificationStepStatus.Pending => "pending", + VerificationStepStatus.InReview => "in_review", + VerificationStepStatus.Passed => "passed", + VerificationStepStatus.Failed => "failed", + VerificationStepStatus.Expired => "expired", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null) + }; + + public static VerificationStatus ParseStatus(string code) => code switch + { + "not_started" => VerificationStatus.NotStarted, + "pending" => VerificationStatus.Pending, + "in_review" => VerificationStatus.InReview, + "approved" => VerificationStatus.Approved, + "rejected" => VerificationStatus.Rejected, + "suspended" => VerificationStatus.Suspended, + _ => throw new ArgumentOutOfRangeException(nameof(code), code, "Unknown verification status code.") + }; + + public static VerificationStepStatus ParseStepStatus(string code) => code switch + { + "not_started" => VerificationStepStatus.NotStarted, + "pending" => VerificationStepStatus.Pending, + "in_review" => VerificationStepStatus.InReview, + "passed" => VerificationStepStatus.Passed, + "failed" => VerificationStepStatus.Failed, + "expired" => VerificationStepStatus.Expired, + _ => throw new ArgumentOutOfRangeException(nameof(code), code, "Unknown verification step status code.") + }; + + /// Lenient parse for a query-string filter; returns null for a missing/unknown value. + public static VerificationStepStatus? TryParseStepStatus(string? code) => code switch + { + "not_started" => VerificationStepStatus.NotStarted, + "pending" => VerificationStepStatus.Pending, + "in_review" => VerificationStepStatus.InReview, + "passed" => VerificationStepStatus.Passed, + "failed" => VerificationStepStatus.Failed, + "expired" => VerificationStepStatus.Expired, + _ => null + }; +} diff --git a/server/src/Core/Baya.Domain/Entities/Verification/VerificationDocument.cs b/server/src/Core/Baya.Domain/Entities/Verification/VerificationDocument.cs new file mode 100644 index 0000000..1ace366 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Verification/VerificationDocument.cs @@ -0,0 +1,29 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Verification; + +/// +/// Metadata for an uploaded evidence file — bytes never touch the DB. The file lives in object +/// storage behind a short-lived signed URL keyed by ; the +/// detects tampering/swap after upload. Never public. +/// +public class VerificationDocument : BaseEntity +{ + public long StepId { get; set; } + public VerificationStep Step { get; set; } = null!; + + /// The opaque IObjectStorage key the bytes live under. + public string ObjectStorageKey { get; set; } = string.Empty; + + /// Content hash (hex) to detect a later tamper/swap of the stored object. + public string IntegrityHash { get; set; } = string.Empty; + + public string ContentType { get; set; } = string.Empty; + public long FileSizeBytes { get; set; } + public string? OriginalFileName { get; set; } + + public int UploadedByUserId { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Verification/VerificationStatus.cs b/server/src/Core/Baya.Domain/Entities/Verification/VerificationStatus.cs new file mode 100644 index 0000000..d2f69f0 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Verification/VerificationStatus.cs @@ -0,0 +1,16 @@ +namespace Baya.Domain.Entities.Verification; + +/// +/// The aggregate state of a nurse's verification (the single source of verification truth). Persisted as +/// its snake_case code (see ). The derived nurse_profiles.is_verified +/// boolean is written solely by the finalize transaction when this reaches . +/// +public enum VerificationStatus +{ + NotStarted, + Pending, + InReview, + Approved, + Rejected, + Suspended +} diff --git a/server/src/Core/Baya.Domain/Entities/Verification/VerificationStep.cs b/server/src/Core/Baya.Domain/Entities/Verification/VerificationStep.cs new file mode 100644 index 0000000..fe7f136 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Verification/VerificationStep.cs @@ -0,0 +1,37 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Verification; + +/// +/// One step per nurse per pipeline step-type. Carries the raw KYC-vendor payload +/// () for audit, an optional for time-limited +/// steps, and a snapshot of the step-type's automation flag () — read the +/// snapshot, never the live step-type, so historical records survive later catalog edits. +/// +public class VerificationStep : BaseEntity +{ + public long NurseVerificationId { get; set; } + public NurseVerification NurseVerification { get; set; } = null!; + + public long StepTypeId { get; set; } + public VerificationStepType StepType { get; set; } = null!; + + public VerificationStepStatus Status { get; set; } = VerificationStepStatus.Pending; + + /// Raw KYC-vendor response, kept so the audit trail survives the mock→real swap. + public string? ExternalResponseJson { get; set; } + + /// When a time-limited step lapses (criminal-record especially); the scanner reverts on it. + public DateTimeOffset? ExpiresAt { get; set; } + + /// Snapshotted from the step-type at seed time — never re-read from the live step-type. + public bool IsAutomated { get; set; } + + public DateTimeOffset? StartedAt { get; set; } + public DateTimeOffset? CompletedAt { get; set; } + + public string? FailureReason { get; set; } + + public ICollection Documents { get; set; } = new List(); +} diff --git a/server/src/Core/Baya.Domain/Entities/Verification/VerificationStepStatus.cs b/server/src/Core/Baya.Domain/Entities/Verification/VerificationStepStatus.cs new file mode 100644 index 0000000..787de59 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Verification/VerificationStepStatus.cs @@ -0,0 +1,16 @@ +namespace Baya.Domain.Entities.Verification; + +/// +/// The state of a single verification step. = awaiting submission/automation; +/// = awaiting a manual admin decision; = a time-limited +/// credential lapsed (the scanner reverts it and re-gates bookability). Persisted as its snake_case code. +/// +public enum VerificationStepStatus +{ + NotStarted, + Pending, + InReview, + Passed, + Failed, + Expired +} diff --git a/server/src/Core/Baya.Domain/Entities/Verification/VerificationStepType.cs b/server/src/Core/Baya.Domain/Entities/Verification/VerificationStepType.cs new file mode 100644 index 0000000..cd688fd --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Verification/VerificationStepType.cs @@ -0,0 +1,29 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Verification; + +/// +/// The admin catalog of pipeline steps — data, not a code enum. Adding a regulatory requirement is one +/// row ( is the stable machine key). is snapshotted onto each +/// at seed time, so toggling it here never rewrites the meaning of past +/// verifications. Deactivate () rather than delete — no soft-delete here. +/// +public class VerificationStepType : BaseEntity +{ + public string Code { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string? Description { get; set; } + + public bool IsRequired { get; set; } + public bool IsAutomated { get; set; } + + /// Informational vendor tag (e.g. shahkar, identity_kyc_vendor). The real seam + /// is config-selected; nothing branches on this string. + public string? AutomationProvider { get; set; } + + public int SortOrder { get; set; } + public bool IsActive { get; set; } = true; + + public ICollection Steps { get; set; } = new List(); +} diff --git a/server/src/Core/Baya.Domain/Entities/Verification/VerificationStepTypeCodes.cs b/server/src/Core/Baya.Domain/Entities/Verification/VerificationStepTypeCodes.cs new file mode 100644 index 0000000..bb122ae --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Verification/VerificationStepTypeCodes.cs @@ -0,0 +1,48 @@ +namespace Baya.Domain.Entities.Verification; + +/// +/// The six stable machine codes of the seeded pipeline steps. The pipeline is data-driven — these live as +/// rows in verification_step_types, not as a switch the automated-run endpoints branch on. A new +/// regulatory step (e.g. professional-liability insurance) is one INSERT, never a new constant here. +/// +public static class VerificationStepTypeCodes +{ + public const string IdentityKyc = "identity_kyc"; + public const string ShahkarMatch = "shahkar_match"; + public const string MohCompetencyLicense = "moh_competency_license"; + public const string InoMembership = "ino_membership"; + public const string CriminalRecord = "criminal_record"; + public const string BankAccountVerification = "bank_account_verification"; + + /// Steps that, on admin pass, record a row in nurse_credentials. + public static readonly IReadOnlyList CredentialBearing = + [MohCompetencyLicense, InoMembership, CriminalRecord]; + + /// The time-limited step whose certificate expires (drives the expiry scanner re-gate). + public const string TimeLimited = CriminalRecord; +} + +/// Stable codes for nurse_credentials.credential_type (aligned with the step codes). +public static class CredentialTypes +{ + public const string MohCompetencyLicense = VerificationStepTypeCodes.MohCompetencyLicense; + public const string InoMembership = VerificationStepTypeCodes.InoMembership; + public const string CriminalRecord = VerificationStepTypeCodes.CriminalRecord; +} + +/// Stable codes for nurse_credentials.verification_method. +public static class VerificationMethods +{ + public const string Manual = "manual"; + public const string Portal = "portal"; + public const string Api = "api"; +} + +/// Stable codes for verification_step_types.automation_provider (informational; the mock +/// swap is config-selected, never branched on here). +public static class AutomationProviders +{ + public const string IdentityKycVendor = "identity_kyc_vendor"; + public const string Shahkar = "shahkar"; + public const string Sheba = "sheba"; +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockCredentialVerifier.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockCredentialVerifier.cs new file mode 100644 index 0000000..22f0604 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockCredentialVerifier.cs @@ -0,0 +1,23 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Domain.Entities.Verification; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Default — and the mock. MoH پروانه صلاحیت حرفه‌ای and INO membership +/// have no public B2B API, so verification is a manual admin review of the uploaded document against +/// the official portal: every call returns +/// with verification_method = manual. When an api/portal source becomes available, a +/// real implementation replaces this registration and starts returning +/// / +/// with the matching method — callers are unchanged. +/// +public sealed class MockCredentialVerifier : ICredentialVerifier +{ + public Task VerifyAsync(string credentialType, string? credentialNumber, CancellationToken cancellationToken = default) + => Task.FromResult(new CredentialVerificationResult( + CredentialVerificationStatus.RequiresManualReview, + VerificationMethods.Manual, + ExternalResponseJson: null)); +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockIdentityKycProvider.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockIdentityKycProvider.cs new file mode 100644 index 0000000..c9e4bf4 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockIdentityKycProvider.cs @@ -0,0 +1,57 @@ +#nullable enable +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Baya.Application.Contracts.Common; +using Microsoft.Extensions.Options; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Mock : a deterministic fake identity + liveness check — no real +/// OCR/liveness call. Passes every well-formed national id except the configured +/// . On pass it reports a matched name and populates the +/// verified identity. A real Iranian e-KYC vendor (Finnotech / U-ID / Jibbit / Farashensa / Verify / +/// Kavoshak) swaps in by a registration change — callers are unchanged. +/// +public sealed class MockIdentityKycProvider(IOptions options) : IIdentityKycProvider +{ + private readonly IdentityKycOptions _options = options.Value.IdentityKyc; + + public Task VerifyAsync(string nationalId, string? livenessPayload, CancellationToken cancellationToken = default) + { + var id = (nationalId ?? string.Empty).Trim(); + var vendorRef = $"MOCK-KYC-{Token(id)}"; + var wellFormed = id.Length == 10 && id.All(char.IsDigit); + + if (!wellFormed || string.Equals(id, _options.FailNationalId, StringComparison.Ordinal)) + { + return Task.FromResult(new IdentityKycResult( + Passed: false, + MatchedName: null, + VendorRef: vendorRef, + ExternalResponseJson: Payload("fail", id, livenessPayload, passed: false), + FailureReason: "Identity could not be verified against the civil registry.")); + } + + return Task.FromResult(new IdentityKycResult( + Passed: true, + MatchedName: _options.MatchedName, + VendorRef: vendorRef, + ExternalResponseJson: Payload("pass", id, livenessPayload, passed: true), + FailureReason: null)); + } + + private static string Payload(string outcome, string nationalId, string? liveness, bool passed) + => JsonSerializer.Serialize(new + { + provider = "mock_identity_kyc", + outcome, + passed, + national_id_present = !string.IsNullOrEmpty(nationalId), + liveness_present = !string.IsNullOrEmpty(liveness) + }); + + 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/MockShahkarVerifier.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockShahkarVerifier.cs new file mode 100644 index 0000000..5f23edd --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockShahkarVerifier.cs @@ -0,0 +1,68 @@ +#nullable enable +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Baya.Application.Contracts.Common; +using Microsoft.Extensions.Options; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Mock : a deterministic fake شاهکار phone↔national-id inquiry — no real +/// Shahkar/KYC call. Matches every pair except the configured +/// (returns the explicit shared-SIM failure state) and +/// (returns a plain mismatch). The vendor ref is derived from the inputs, so re-running is idempotent. A +/// real Finnotech/KYC client swaps in by a registration change — callers are unchanged. +/// +public sealed class MockShahkarVerifier(IOptions options) : IShahkarVerifier +{ + private readonly ShahkarOptions _options = options.Value.Shahkar; + + public Task MatchAsync(string? phoneNumber, string? nationalId, CancellationToken cancellationToken = default) + { + var phone = (phoneNumber ?? string.Empty).Trim(); + var vendorRef = $"MOCK-SHAHKAR-{Token(phone + "|" + (nationalId ?? string.Empty))}"; + + if (string.Equals(phone, _options.SharedSimPhone, StringComparison.Ordinal)) + { + var json = Payload("shared_sim", phone, nationalId, matched: false); + return Task.FromResult(new ShahkarMatchResult( + Matched: false, + IsSharedSim: true, + VendorRef: vendorRef, + ExternalResponseJson: json, + FailureReason: "This SIM appears to be registered to a family member. Please use a SIM registered in your own name.")); + } + + if (string.Equals(nationalId, _options.MismatchNationalId, StringComparison.Ordinal)) + { + var json = Payload("mismatch", phone, nationalId, matched: false); + return Task.FromResult(new ShahkarMatchResult( + Matched: false, + IsSharedSim: false, + VendorRef: vendorRef, + ExternalResponseJson: json, + FailureReason: "The phone number is not registered to your national ID.")); + } + + return Task.FromResult(new ShahkarMatchResult( + Matched: true, + IsSharedSim: false, + VendorRef: vendorRef, + ExternalResponseJson: Payload("match", phone, nationalId, matched: true), + FailureReason: null)); + } + + private static string Payload(string outcome, string phone, string? nationalId, bool matched) + => JsonSerializer.Serialize(new + { + provider = "mock_shahkar", + outcome, + matched, + phone_last4 = phone.Length >= 4 ? phone[^4..] : phone, + national_id_present = !string.IsNullOrEmpty(nationalId) + }); + + 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 078da32..2fd7526 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs @@ -12,6 +12,38 @@ public sealed class SeamOptions public ObjectStorageOptions ObjectStorage { get; set; } = new(); public BankOwnershipOptions BankOwnership { get; set; } = new(); public GeocodingOptions Geocoding { get; set; } = new(); + public ShahkarOptions Shahkar { get; set; } = new(); + public IdentityKycOptions IdentityKyc { get; set; } = new(); +} + +/// +/// Tunes the mock IShahkarVerifier (phone↔national-id binding). A submitted phone equal to +/// returns the explicit shared-SIM failure; a national id equal to +/// returns a plain mismatch; every other pair matches. The real vendor +/// implementation ignores these. +/// +public sealed class ShahkarOptions +{ + /// The designated test phone that returns the shared-SIM failure state. + public string SharedSimPhone { get; set; } = "09120000000"; + + /// The designated test national id that returns a plain phone↔national-id mismatch. + public string MismatchNationalId { get; set; } = "1111111111"; +} + +/// +/// Tunes the mock IIdentityKycProvider. A national id equal to fails +/// KYC; every other (well-formed) national id passes with . The real e-KYC vendor +/// implementation ignores these. +/// +public sealed class IdentityKycOptions +{ + /// The designated test national id that fails identity KYC. + public string FailNationalId { get; set; } = "0000000000"; + + /// The name the mock reports as matched on a passing KYC (informational; the authoritative + /// identity name for credential cross-check comes from the users row). + public string MatchedName { get; set; } = "Verified Nurse"; } /// diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs index ed3a2f9..2f32d35 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs @@ -36,6 +36,13 @@ public static class ServiceCollectionExtension // centroid with no network call; a real Neshan/Google geocoding client replaces this registration. services.AddSingleton(); + // Nurse-verification vendors (backend-phase-6). All three are deterministic mocks; a real Iranian + // e-KYC vendor / Shahkar bridge / (future) MoH-INO portal swaps in by a registration change only — + // no mock behaviour is baked into any handler call site. + services.AddSingleton(); + services.AddSingleton(); + 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 0f808cd..cfd8d89 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs @@ -3,6 +3,7 @@ using Baya.Application.Contracts.Common; using Baya.Domain.Common; using Baya.Domain.Entities.Identity; using Baya.Domain.Entities.User; +using Baya.Domain.Entities.Verification; using Baya.Infrastructure.Persistence.ValueConversion; using Baya.SharedKernel.Extensions; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; @@ -131,5 +132,12 @@ public class ApplicationDbContext: IdentityDbContext a.RecipientName).HasConversion(encrypted); builder.Property(a => a.RecipientPhone).HasConversion(encrypted); }); + + // b6 credential PII: the license/membership number is encrypted at rest through the same seam and + // is never serialized on the wire (the trust badge exposes credential types, never numbers). + modelBuilder.Entity(builder => + { + builder.Property(c => c.CredentialNumber).HasConversion(encrypted); + }); } } \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs index 8a3be8c..e80dd8f 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs @@ -43,6 +43,7 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("NurseCredentials", "verif"); + + builder.Property(c => c.CredentialType).HasMaxLength(50).IsRequired(); + // credential_number is encrypted at rest (converter wired in ApplicationDbContext) — never serialized. + builder.Property(c => c.CredentialNumber).HasMaxLength(256).IsRequired(); + builder.Property(c => c.HolderNameSnapshot).HasMaxLength(200).IsRequired(); + builder.Property(c => c.IssuingAuthority).HasMaxLength(200).IsRequired(); + builder.Property(c => c.VerificationSource).HasMaxLength(300); + builder.Property(c => c.VerificationMethod).HasMaxLength(20).IsRequired(); + + // Badge + renewal reads: the nurse's non-expired credentials by type. + builder.HasIndex(c => new { c.NurseId, c.CredentialType }); + + builder.HasOne() + .WithMany() + .HasForeignKey(c => c.NurseId) + .IsRequired(); + + builder.HasOne() + .WithMany() + .HasForeignKey(c => c.VerifiedByAdminId) + .IsRequired(false); + + builder.HasQueryFilter(c => c.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/NurseVerificationConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/NurseVerificationConfig.cs new file mode 100644 index 0000000..1159094 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/NurseVerificationConfig.cs @@ -0,0 +1,38 @@ +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.User; +using Baya.Domain.Entities.Verification; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig; + +internal sealed class NurseVerificationConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("NurseVerifications", "verif"); + + // Status persists as its stable snake_case code — readable and queryable (WHERE status = 'approved'). + builder.Property(v => v.Status) + .HasConversion(s => s.ToCode(), s => VerificationCodes.ParseStatus(s)) + .HasMaxLength(20) + .IsRequired(); + + builder.Property(v => v.RejectionReason).HasMaxLength(1000); + builder.Property(v => v.InternalNotes).HasMaxLength(2000); + + // 1:1 with the nurse profile — the sole verification header per nurse. + builder.HasIndex(v => v.NurseId).IsUnique(); + builder.HasOne(v => v.Nurse) + .WithMany() + .HasForeignKey(v => v.NurseId) + .IsRequired(); + + builder.HasOne() + .WithMany() + .HasForeignKey(v => v.ReviewedByAdminId) + .IsRequired(false); + + builder.HasQueryFilter(v => v.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/VerificationDocumentConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/VerificationDocumentConfig.cs new file mode 100644 index 0000000..10cda68 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/VerificationDocumentConfig.cs @@ -0,0 +1,32 @@ +using Baya.Domain.Entities.User; +using Baya.Domain.Entities.Verification; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig; + +internal sealed class VerificationDocumentConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("VerificationDocuments", "verif"); + + // Metadata only — the bytes live in object storage behind a signed URL, never in the DB. + builder.Property(d => d.ObjectStorageKey).HasMaxLength(400).IsRequired(); + builder.Property(d => d.IntegrityHash).HasMaxLength(64).IsRequired(); + builder.Property(d => d.ContentType).HasMaxLength(100).IsRequired(); + builder.Property(d => d.OriginalFileName).HasMaxLength(260); + + builder.HasOne(d => d.Step) + .WithMany(s => s.Documents) + .HasForeignKey(d => d.StepId) + .IsRequired(); + + builder.HasOne() + .WithMany() + .HasForeignKey(d => d.UploadedByUserId) + .IsRequired(); + + builder.HasQueryFilter(d => d.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/VerificationStepConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/VerificationStepConfig.cs new file mode 100644 index 0000000..9899070 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/VerificationStepConfig.cs @@ -0,0 +1,38 @@ +using Baya.Domain.Entities.Verification; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig; + +internal sealed class VerificationStepConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("VerificationSteps", "verif"); + + builder.Property(s => s.Status) + .HasConversion(s => s.ToCode(), s => VerificationCodes.ParseStepStatus(s)) + .HasMaxLength(20) + .IsRequired(); + + // Raw KYC-vendor response, kept for audit — no length cap (NVARCHAR(MAX)). + builder.Property(s => s.ExternalResponseJson); + builder.Property(s => s.FailureReason).HasMaxLength(500); + builder.Property(s => s.IsAutomated).HasDefaultValue(false); + + // One step per (verification, step-type) — the idempotent-submit backstop. + builder.HasIndex(s => new { s.NurseVerificationId, s.StepTypeId }) + .IsUnique() + .HasDatabaseName("UX_VerificationSteps_Verification_StepType"); + + builder.HasOne(s => s.NurseVerification) + .WithMany(v => v.Steps) + .HasForeignKey(s => s.NurseVerificationId) + .IsRequired(); + + builder.HasOne(s => s.StepType) + .WithMany(t => t.Steps) + .HasForeignKey(s => s.StepTypeId) + .IsRequired(); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/VerificationStepTypeConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/VerificationStepTypeConfig.cs new file mode 100644 index 0000000..07324da --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/VerificationStepTypeConfig.cs @@ -0,0 +1,32 @@ +using Baya.Domain.Entities.Verification; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig; + +internal sealed class VerificationStepTypeConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("VerificationStepTypes", "verif"); + + builder.Property(t => t.Code).HasMaxLength(50).IsRequired(); + builder.Property(t => t.DisplayName).HasMaxLength(150).IsRequired(); + builder.Property(t => t.Description).HasMaxLength(500); + builder.Property(t => t.AutomationProvider).HasMaxLength(50); + builder.Property(t => t.IsRequired).HasDefaultValue(false); + builder.Property(t => t.IsAutomated).HasDefaultValue(false); + builder.Property(t => t.SortOrder).HasDefaultValue(0); + builder.Property(t => t.IsActive).HasDefaultValue(true); + + // Stable machine code — the data-driven pipeline's identity; one nurse-step joins on it. + builder.HasIndex(t => t.Code).IsUnique(); + + // Ordered admin/nurse browse of the active catalog. + builder.HasIndex(t => new { t.IsActive, t.SortOrder }); + + // The six step-types are seeded via HasData; the resulting InsertData is captured in a dedicated + // seed migration (SeedVerificationStepTypes), kept separate from the table-creation migration. + builder.HasData(VerificationStepTypeSeed.StepTypes()); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/VerificationStepTypeSeed.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/VerificationStepTypeSeed.cs new file mode 100644 index 0000000..51f8122 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/VerificationConfig/VerificationStepTypeSeed.cs @@ -0,0 +1,54 @@ +namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig; + +using Baya.Domain.Entities.Verification; + +/// +/// The six MVP pipeline step-types, seeded via HasData so the data-driven pipeline exists on a fresh +/// DB (the b1 seeding path). Ids are fixed and deterministic (1…6, sort_order = id) so re-running is +/// idempotent and the model snapshot stays stable. A new regulatory step is one more row — never a code +/// enum. Emitted in its own "seed" migration, separate from the table-creation migration. +/// +internal static class VerificationStepTypeSeed +{ + private static readonly (long Id, string Code, string Display, string Description, bool Required, bool Automated, string Provider)[] Rows = + [ + (1, VerificationStepTypeCodes.IdentityKyc, "Identity Verification (KYC)", + "National-ID validity, name match and photo/video liveness via an Iranian e-KYC vendor.", + true, true, AutomationProviders.IdentityKycVendor), + (2, VerificationStepTypeCodes.ShahkarMatch, "Shahkar Phone Binding", + "Confirms the login SIM is registered to the nurse's own national ID (شاهکار).", + true, true, AutomationProviders.Shahkar), + (3, VerificationStepTypeCodes.MohCompetencyLicense, "MoH Professional Competency License", + "پروانه صلاحیت حرفه‌ای — the MoH-mandated in-home nursing licence (bundles the criminal-record screen). Manual today.", + true, false, null), + (4, VerificationStepTypeCodes.InoMembership, "Nursing Organization (INO) Membership", + "نظام پرستاری membership cross-check (ino.ir). Manual today.", + true, false, null), + (5, VerificationStepTypeCodes.CriminalRecord, "Criminal Record Certificate", + "عدم سوء پیشینه — consent-gated, nurse-uploaded, time-limited (reverts on expiry).", + true, false, null), + (6, VerificationStepTypeCodes.BankAccountVerification, "Bank Account (IBAN) Ownership", + "استعلام شبا — the payout IBAN owner's national ID must equal the verified nurse national ID.", + true, true, AutomationProviders.Sheba), + ]; + + public static object[] StepTypes() + { + var ts = SeedConstants.Timestamp; + return Rows + .Select(r => (object)new + { + r.Id, + r.Code, + DisplayName = r.Display, + Description = (string)r.Description, + IsRequired = r.Required, + IsAutomated = r.Automated, + AutomationProvider = r.Provider, + SortOrder = (int)r.Id, + IsActive = true, + CreatedAt = ts + }) + .ToArray(); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702193307_VerificationPipeline.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702193307_VerificationPipeline.Designer.cs new file mode 100644 index 0000000..d693fea --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702193307_VerificationPipeline.Designer.cs @@ -0,0 +1,3346 @@ +// +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("20260702193307_VerificationPipeline")] + partial class VerificationPipeline + { + /// + 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.Catalog.NurseServiceVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OptionSetHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SessionCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId"); + + b.HasIndex("NurseId", "IsActive"); + + b.HasIndex("NurseId", "ServiceCategoryId", "OptionSetHash") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariants_Nurse_Category_OptionSet") + .HasFilter("[DeletedAt] IS NULL"); + + b.ToTable("NurseServiceVariants", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("OptionValueId") + .HasColumnType("bigint"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId"); + + b.HasIndex("OptionValueId"); + + b.HasIndex("VariantId", "OptionGroupId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariantOptions_Variant_Group"); + + b.ToTable("NurseServiceVariantOptions", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DescriptionFa") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IconKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("ServiceCategories", "catalog"); + + 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)), + IsActive = true, + NameEn = "Elderly Care", + NameFa = "مراقبت از سالمند", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Post-Surgery Recovery", + NameFa = "مراقبت پس از جراحی", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Infant Care", + NameFa = "مراقبت از نوزاد", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chronic Illness Management", + NameFa = "مدیریت بیماری مزمن", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Companionship", + NameFa = "همراهی و مراقبت روزمره", + SortOrder = 5 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId", "SortOrder"); + + b.ToTable("ServiceOptionGroups", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId", "SortOrder"); + + b.ToTable("ServiceOptionValues", "catalog"); + }); + + 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" + }, + new + { + Id = 16L, + 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 between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today).", + Key = "verification_expiry_scan_cadence_hours", + Value = "24" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ProvinceId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ProvinceId", "SortOrder"); + + b.ToTable("Cities", "geo"); + + b.HasData( + new + { + Id = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + ProvinceId = 1L, + SortOrder = 1 + }, + new + { + Id = 102L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Karaj", + NameFa = "کرج", + ProvinceId = 2L, + SortOrder = 1 + }, + new + { + Id = 103L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + ProvinceId = 3L, + SortOrder = 1 + }, + new + { + Id = 104L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shiraz", + NameFa = "شیراز", + ProvinceId = 4L, + SortOrder = 1 + }, + new + { + Id = 105L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mashhad", + NameFa = "مشهد", + ProvinceId = 5L, + SortOrder = 1 + }, + new + { + Id = 106L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tabriz", + NameFa = "تبریز", + ProvinceId = 6L, + SortOrder = 1 + }, + new + { + Id = 107L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Urmia", + NameFa = "ارومیه", + ProvinceId = 7L, + SortOrder = 1 + }, + new + { + Id = 108L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ahvaz", + NameFa = "اهواز", + ProvinceId = 8L, + SortOrder = 1 + }, + new + { + Id = 109L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + ProvinceId = 9L, + SortOrder = 1 + }, + new + { + Id = 110L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + ProvinceId = 10L, + SortOrder = 1 + }, + new + { + Id = 111L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Rasht", + NameFa = "رشت", + ProvinceId = 11L, + SortOrder = 1 + }, + new + { + Id = 112L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sari", + NameFa = "ساری", + ProvinceId = 12L, + SortOrder = 1 + }, + new + { + Id = 113L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Arak", + NameFa = "اراک", + ProvinceId = 13L, + SortOrder = 1 + }, + new + { + Id = 114L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + ProvinceId = 14L, + SortOrder = 1 + }, + new + { + Id = 115L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + ProvinceId = 15L, + SortOrder = 1 + }, + new + { + Id = 116L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + ProvinceId = 16L, + SortOrder = 1 + }, + new + { + Id = 117L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bojnord", + NameFa = "بجنورد", + ProvinceId = 17L, + SortOrder = 1 + }, + new + { + Id = 118L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Birjand", + NameFa = "بیرجند", + ProvinceId = 18L, + SortOrder = 1 + }, + new + { + Id = 119L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + ProvinceId = 19L, + SortOrder = 1 + }, + new + { + Id = 120L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sanandaj", + NameFa = "سنندج", + ProvinceId = 20L, + SortOrder = 1 + }, + new + { + Id = 121L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khorramabad", + NameFa = "خرم‌آباد", + ProvinceId = 21L, + SortOrder = 1 + }, + new + { + Id = 122L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gorgan", + NameFa = "گرگان", + ProvinceId = 22L, + SortOrder = 1 + }, + new + { + Id = 123L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bandar Abbas", + NameFa = "بندرعباس", + ProvinceId = 23L, + SortOrder = 1 + }, + new + { + Id = 124L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + ProvinceId = 24L, + SortOrder = 1 + }, + new + { + Id = 125L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + ProvinceId = 25L, + SortOrder = 1 + }, + new + { + Id = 126L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + ProvinceId = 26L, + SortOrder = 1 + }, + new + { + Id = 127L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + ProvinceId = 27L, + SortOrder = 1 + }, + new + { + Id = 128L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zahedan", + NameFa = "زاهدان", + ProvinceId = 28L, + SortOrder = 1 + }, + new + { + Id = 129L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shahrekord", + NameFa = "شهرکرد", + ProvinceId = 29L, + SortOrder = 1 + }, + new + { + Id = 130L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yasuj", + NameFa = "یاسوج", + ProvinceId = 30L, + SortOrder = 1 + }, + new + { + Id = 131L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + ProvinceId = 31L, + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("CityId", "SortOrder"); + + b.ToTable("Districts", "geo"); + + b.HasData( + new + { + Id = 1001L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 1", + NameFa = "منطقه ۱", + SortOrder = 1 + }, + new + { + Id = 1002L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 2", + NameFa = "منطقه ۲", + SortOrder = 2 + }, + new + { + Id = 1003L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 3", + NameFa = "منطقه ۳", + SortOrder = 3 + }, + new + { + Id = 1004L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 4", + NameFa = "منطقه ۴", + SortOrder = 4 + }, + new + { + Id = 1005L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 5", + NameFa = "منطقه ۵", + SortOrder = 5 + }, + new + { + Id = 1006L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 6", + NameFa = "منطقه ۶", + SortOrder = 6 + }, + new + { + Id = 1007L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 7", + NameFa = "منطقه ۷", + SortOrder = 7 + }, + new + { + Id = 1008L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 8", + NameFa = "منطقه ۸", + SortOrder = 8 + }, + new + { + Id = 1009L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 9", + NameFa = "منطقه ۹", + SortOrder = 9 + }, + new + { + Id = 1010L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 10", + NameFa = "منطقه ۱۰", + SortOrder = 10 + }, + new + { + Id = 1011L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 11", + NameFa = "منطقه ۱۱", + SortOrder = 11 + }, + new + { + Id = 1012L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 12", + NameFa = "منطقه ۱۲", + SortOrder = 12 + }, + new + { + Id = 1013L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 13", + NameFa = "منطقه ۱۳", + SortOrder = 13 + }, + new + { + Id = 1014L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 14", + NameFa = "منطقه ۱۴", + SortOrder = 14 + }, + new + { + Id = 1015L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 15", + NameFa = "منطقه ۱۵", + SortOrder = 15 + }, + new + { + Id = 1016L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 16", + NameFa = "منطقه ۱۶", + SortOrder = 16 + }, + new + { + Id = 1017L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 17", + NameFa = "منطقه ۱۷", + SortOrder = 17 + }, + new + { + Id = 1018L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 18", + NameFa = "منطقه ۱۸", + SortOrder = 18 + }, + new + { + Id = 1019L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 19", + NameFa = "منطقه ۱۹", + SortOrder = 19 + }, + new + { + Id = 1020L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 20", + NameFa = "منطقه ۲۰", + SortOrder = 20 + }, + new + { + Id = 1021L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 21", + NameFa = "منطقه ۲۱", + SortOrder = 21 + }, + new + { + Id = 1022L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 22", + NameFa = "منطقه ۲۲", + SortOrder = 22 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("DistrictId"); + + b.HasIndex("NurseId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("NurseId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.ToTable("NurseServiceAreas", "geo"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("Provinces", "geo"); + + 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)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Alborz", + NameFa = "البرز", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Fars", + NameFa = "فارس", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Razavi Khorasan", + NameFa = "خراسان رضوی", + SortOrder = 5 + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "East Azerbaijan", + NameFa = "آذربایجان شرقی", + SortOrder = 6 + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "West Azerbaijan", + NameFa = "آذربایجان غربی", + SortOrder = 7 + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khuzestan", + NameFa = "خوزستان", + SortOrder = 8 + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + SortOrder = 9 + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + SortOrder = 10 + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gilan", + NameFa = "گیلان", + SortOrder = 11 + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mazandaran", + NameFa = "مازندران", + SortOrder = 12 + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Markazi", + NameFa = "مرکزی", + SortOrder = 13 + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + SortOrder = 14 + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + SortOrder = 15 + }, + new + { + Id = 16L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + SortOrder = 16 + }, + new + { + Id = 17L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "North Khorasan", + NameFa = "خراسان شمالی", + SortOrder = 17 + }, + new + { + Id = 18L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "South Khorasan", + NameFa = "خراسان جنوبی", + SortOrder = 18 + }, + new + { + Id = 19L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + SortOrder = 19 + }, + new + { + Id = 20L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kurdistan", + NameFa = "کردستان", + SortOrder = 20 + }, + new + { + Id = 21L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Lorestan", + NameFa = "لرستان", + SortOrder = 21 + }, + new + { + Id = 22L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Golestan", + NameFa = "گلستان", + SortOrder = 22 + }, + new + { + Id = 23L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hormozgan", + NameFa = "هرمزگان", + SortOrder = 23 + }, + new + { + Id = 24L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + SortOrder = 24 + }, + new + { + Id = 25L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + SortOrder = 25 + }, + new + { + Id = 26L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + SortOrder = 26 + }, + new + { + Id = 27L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + SortOrder = 27 + }, + new + { + Id = 28L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sistan and Baluchestan", + NameFa = "سیستان و بلوچستان", + SortOrder = 28 + }, + new + { + Id = 29L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chaharmahal and Bakhtiari", + NameFa = "چهارمحال و بختیاری", + SortOrder = 29 + }, + new + { + Id = 30L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kohgiluyeh and Boyer-Ahmad", + NameFa = "کهگیلویه و بویراحمد", + SortOrder = 30 + }, + new + { + Id = 31L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + SortOrder = 31 + }); + }); + + 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.CustomerAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddressLine") + .HasColumnType("nvarchar(max)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Latitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PostalCode") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("CustomerId") + .IsUnique() + .HasDatabaseName("UX_CustomerAddresses_Customer_Primary") + .HasFilter("[IsPrimary] = 1 AND [DeletedAt] IS NULL"); + + b.HasIndex("DistrictId"); + + b.ToTable("CustomerAddresses", "usr"); + }); + + 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.Verification.NurseCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CredentialNumber") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("CredentialType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExpiresAt") + .HasColumnType("date"); + + b.Property("HolderNameSnapshot") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IssuedAt") + .HasColumnType("date"); + + b.Property("IssuingAuthority") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("VerificationMethod") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VerificationSource") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VerifiedByAdminId"); + + b.HasIndex("NurseId", "CredentialType"); + + b.ToTable("NurseCredentials", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("InternalNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("RejectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ReviewedByAdminId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SubmittedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SuspendedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("NurseId") + .IsUnique(); + + b.HasIndex("ReviewedByAdminId"); + + b.ToTable("NurseVerifications", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IntegrityHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("ObjectStorageKey") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("OriginalFileName") + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("StepId") + .HasColumnType("bigint"); + + b.Property("UploadedByUserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StepId"); + + b.HasIndex("UploadedByUserId"); + + b.ToTable("VerificationDocuments", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalResponseJson") + .HasColumnType("nvarchar(max)"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseVerificationId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("StepTypeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("StepTypeId"); + + b.HasIndex("NurseVerificationId", "StepTypeId") + .IsUnique() + .HasDatabaseName("UX_VerificationSteps_Verification_StepType"); + + b.ToTable("VerificationSteps", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AutomationProvider") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("VerificationStepTypes", "verif"); + }); + + 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.Catalog.NurseServiceVariant", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("Variants") + .HasForeignKey("ServiceCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Nurse"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany() + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionValue", "OptionValue") + .WithMany() + .HasForeignKey("OptionValueId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany("Options") + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + + b.Navigation("OptionValue"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("OptionGroups") + .HasForeignKey("ServiceCategoryId"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany("Values") + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.HasOne("Baya.Domain.Entities.Geography.Province", "Province") + .WithMany("Cities") + .HasForeignKey("ProvinceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Province"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany("Districts") + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.Navigation("City"); + + b.Navigation("Customer"); + + b.Navigation("District"); + }); + + 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.Verification.NurseCredential", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("VerifiedByAdminId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ReviewedByAdminId"); + + b.Navigation("Nurse"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.HasOne("Baya.Domain.Entities.Verification.VerificationStep", "Step") + .WithMany("Documents") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UploadedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.HasOne("Baya.Domain.Entities.Verification.NurseVerification", "NurseVerification") + .WithMany("Steps") + .HasForeignKey("NurseVerificationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Verification.VerificationStepType", "StepType") + .WithMany("Steps") + .HasForeignKey("StepTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("NurseVerification"); + + b.Navigation("StepType"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Navigation("OptionGroups"); + + b.Navigation("Variants"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Navigation("Districts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Navigation("Cities"); + }); + + 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"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Navigation("Documents"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702193307_VerificationPipeline.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702193307_VerificationPipeline.cs new file mode 100644 index 0000000..e30544d --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702193307_VerificationPipeline.cs @@ -0,0 +1,302 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Baya.Infrastructure.Persistence.Migrations +{ + /// + public partial class VerificationPipeline : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "verif"); + + migrationBuilder.CreateTable( + name: "NurseCredentials", + schema: "verif", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + NurseId = table.Column(type: "bigint", nullable: false), + CredentialType = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + CredentialNumber = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + HolderNameSnapshot = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + IssuingAuthority = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + IssuedAt = table.Column(type: "date", nullable: true), + ExpiresAt = table.Column(type: "date", nullable: true), + VerificationSource = table.Column(type: "nvarchar(300)", maxLength: 300, nullable: true), + VerificationMethod = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + VerifiedByAdminId = table.Column(type: "int", nullable: true), + 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_NurseCredentials", x => x.Id); + table.ForeignKey( + name: "FK_NurseCredentials_NurseProfiles_NurseId", + column: x => x.NurseId, + principalSchema: "usr", + principalTable: "NurseProfiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_NurseCredentials_Users_VerifiedByAdminId", + column: x => x.VerifiedByAdminId, + principalSchema: "usr", + principalTable: "Users", + principalColumn: "UserId"); + }); + + migrationBuilder.CreateTable( + name: "NurseVerifications", + schema: "verif", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + NurseId = table.Column(type: "bigint", nullable: false), + Status = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + SubmittedAt = table.Column(type: "datetimeoffset", nullable: true), + ApprovedAt = table.Column(type: "datetimeoffset", nullable: true), + RejectedAt = table.Column(type: "datetimeoffset", nullable: true), + SuspendedAt = table.Column(type: "datetimeoffset", nullable: true), + RejectionReason = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), + ReviewedByAdminId = table.Column(type: "int", nullable: true), + InternalNotes = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: true), + 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_NurseVerifications", x => x.Id); + table.ForeignKey( + name: "FK_NurseVerifications_NurseProfiles_NurseId", + column: x => x.NurseId, + principalSchema: "usr", + principalTable: "NurseProfiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_NurseVerifications_Users_ReviewedByAdminId", + column: x => x.ReviewedByAdminId, + principalSchema: "usr", + principalTable: "Users", + principalColumn: "UserId"); + }); + + migrationBuilder.CreateTable( + name: "VerificationStepTypes", + schema: "verif", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Code = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + DisplayName = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + IsRequired = table.Column(type: "bit", nullable: false, defaultValue: false), + IsAutomated = table.Column(type: "bit", nullable: false, defaultValue: false), + AutomationProvider = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), + SortOrder = table.Column(type: "int", nullable: false, defaultValue: 0), + 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_VerificationStepTypes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "VerificationSteps", + schema: "verif", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + NurseVerificationId = table.Column(type: "bigint", nullable: false), + StepTypeId = table.Column(type: "bigint", nullable: false), + Status = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + ExternalResponseJson = table.Column(type: "nvarchar(max)", nullable: true), + ExpiresAt = table.Column(type: "datetimeoffset", nullable: true), + IsAutomated = table.Column(type: "bit", nullable: false, defaultValue: false), + StartedAt = table.Column(type: "datetimeoffset", nullable: true), + CompletedAt = table.Column(type: "datetimeoffset", nullable: true), + FailureReason = table.Column(type: "nvarchar(500)", maxLength: 500, 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_VerificationSteps", x => x.Id); + table.ForeignKey( + name: "FK_VerificationSteps_NurseVerifications_NurseVerificationId", + column: x => x.NurseVerificationId, + principalSchema: "verif", + principalTable: "NurseVerifications", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_VerificationSteps_VerificationStepTypes_StepTypeId", + column: x => x.StepTypeId, + principalSchema: "verif", + principalTable: "VerificationStepTypes", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "VerificationDocuments", + schema: "verif", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + StepId = table.Column(type: "bigint", nullable: false), + ObjectStorageKey = table.Column(type: "nvarchar(400)", maxLength: 400, nullable: false), + IntegrityHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + ContentType = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + FileSizeBytes = table.Column(type: "bigint", nullable: false), + OriginalFileName = table.Column(type: "nvarchar(260)", maxLength: 260, nullable: true), + UploadedByUserId = table.Column(type: "int", nullable: false), + 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_VerificationDocuments", x => x.Id); + table.ForeignKey( + name: "FK_VerificationDocuments_Users_UploadedByUserId", + column: x => x.UploadedByUserId, + principalSchema: "usr", + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_VerificationDocuments_VerificationSteps_StepId", + column: x => x.StepId, + principalSchema: "verif", + principalTable: "VerificationSteps", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.InsertData( + schema: "ops", + table: "PlatformConfigs", + columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" }, + values: new object[] { 16L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today).", "verification_expiry_scan_cadence_hours", null, null, "24" }); + + migrationBuilder.CreateIndex( + name: "IX_NurseCredentials_NurseId_CredentialType", + schema: "verif", + table: "NurseCredentials", + columns: new[] { "NurseId", "CredentialType" }); + + migrationBuilder.CreateIndex( + name: "IX_NurseCredentials_VerifiedByAdminId", + schema: "verif", + table: "NurseCredentials", + column: "VerifiedByAdminId"); + + migrationBuilder.CreateIndex( + name: "IX_NurseVerifications_NurseId", + schema: "verif", + table: "NurseVerifications", + column: "NurseId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_NurseVerifications_ReviewedByAdminId", + schema: "verif", + table: "NurseVerifications", + column: "ReviewedByAdminId"); + + migrationBuilder.CreateIndex( + name: "IX_VerificationDocuments_StepId", + schema: "verif", + table: "VerificationDocuments", + column: "StepId"); + + migrationBuilder.CreateIndex( + name: "IX_VerificationDocuments_UploadedByUserId", + schema: "verif", + table: "VerificationDocuments", + column: "UploadedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_VerificationSteps_StepTypeId", + schema: "verif", + table: "VerificationSteps", + column: "StepTypeId"); + + migrationBuilder.CreateIndex( + name: "UX_VerificationSteps_Verification_StepType", + schema: "verif", + table: "VerificationSteps", + columns: new[] { "NurseVerificationId", "StepTypeId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_VerificationStepTypes_Code", + schema: "verif", + table: "VerificationStepTypes", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_VerificationStepTypes_IsActive_SortOrder", + schema: "verif", + table: "VerificationStepTypes", + columns: new[] { "IsActive", "SortOrder" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "NurseCredentials", + schema: "verif"); + + migrationBuilder.DropTable( + name: "VerificationDocuments", + schema: "verif"); + + migrationBuilder.DropTable( + name: "VerificationSteps", + schema: "verif"); + + migrationBuilder.DropTable( + name: "NurseVerifications", + schema: "verif"); + + migrationBuilder.DropTable( + name: "VerificationStepTypes", + schema: "verif"); + + migrationBuilder.DeleteData( + schema: "ops", + table: "PlatformConfigs", + keyColumn: "Id", + keyValue: 16L); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702193432_SeedVerificationStepTypes.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702193432_SeedVerificationStepTypes.Designer.cs new file mode 100644 index 0000000..f242ea0 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702193432_SeedVerificationStepTypes.Designer.cs @@ -0,0 +1,3423 @@ +// +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("20260702193432_SeedVerificationStepTypes")] + partial class SeedVerificationStepTypes + { + /// + 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.Catalog.NurseServiceVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OptionSetHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SessionCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId"); + + b.HasIndex("NurseId", "IsActive"); + + b.HasIndex("NurseId", "ServiceCategoryId", "OptionSetHash") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariants_Nurse_Category_OptionSet") + .HasFilter("[DeletedAt] IS NULL"); + + b.ToTable("NurseServiceVariants", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("OptionValueId") + .HasColumnType("bigint"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId"); + + b.HasIndex("OptionValueId"); + + b.HasIndex("VariantId", "OptionGroupId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariantOptions_Variant_Group"); + + b.ToTable("NurseServiceVariantOptions", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DescriptionFa") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IconKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("ServiceCategories", "catalog"); + + 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)), + IsActive = true, + NameEn = "Elderly Care", + NameFa = "مراقبت از سالمند", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Post-Surgery Recovery", + NameFa = "مراقبت پس از جراحی", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Infant Care", + NameFa = "مراقبت از نوزاد", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chronic Illness Management", + NameFa = "مدیریت بیماری مزمن", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Companionship", + NameFa = "همراهی و مراقبت روزمره", + SortOrder = 5 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId", "SortOrder"); + + b.ToTable("ServiceOptionGroups", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId", "SortOrder"); + + b.ToTable("ServiceOptionValues", "catalog"); + }); + + 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" + }, + new + { + Id = 16L, + 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 between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today).", + Key = "verification_expiry_scan_cadence_hours", + Value = "24" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ProvinceId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ProvinceId", "SortOrder"); + + b.ToTable("Cities", "geo"); + + b.HasData( + new + { + Id = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + ProvinceId = 1L, + SortOrder = 1 + }, + new + { + Id = 102L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Karaj", + NameFa = "کرج", + ProvinceId = 2L, + SortOrder = 1 + }, + new + { + Id = 103L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + ProvinceId = 3L, + SortOrder = 1 + }, + new + { + Id = 104L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shiraz", + NameFa = "شیراز", + ProvinceId = 4L, + SortOrder = 1 + }, + new + { + Id = 105L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mashhad", + NameFa = "مشهد", + ProvinceId = 5L, + SortOrder = 1 + }, + new + { + Id = 106L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tabriz", + NameFa = "تبریز", + ProvinceId = 6L, + SortOrder = 1 + }, + new + { + Id = 107L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Urmia", + NameFa = "ارومیه", + ProvinceId = 7L, + SortOrder = 1 + }, + new + { + Id = 108L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ahvaz", + NameFa = "اهواز", + ProvinceId = 8L, + SortOrder = 1 + }, + new + { + Id = 109L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + ProvinceId = 9L, + SortOrder = 1 + }, + new + { + Id = 110L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + ProvinceId = 10L, + SortOrder = 1 + }, + new + { + Id = 111L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Rasht", + NameFa = "رشت", + ProvinceId = 11L, + SortOrder = 1 + }, + new + { + Id = 112L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sari", + NameFa = "ساری", + ProvinceId = 12L, + SortOrder = 1 + }, + new + { + Id = 113L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Arak", + NameFa = "اراک", + ProvinceId = 13L, + SortOrder = 1 + }, + new + { + Id = 114L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + ProvinceId = 14L, + SortOrder = 1 + }, + new + { + Id = 115L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + ProvinceId = 15L, + SortOrder = 1 + }, + new + { + Id = 116L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + ProvinceId = 16L, + SortOrder = 1 + }, + new + { + Id = 117L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bojnord", + NameFa = "بجنورد", + ProvinceId = 17L, + SortOrder = 1 + }, + new + { + Id = 118L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Birjand", + NameFa = "بیرجند", + ProvinceId = 18L, + SortOrder = 1 + }, + new + { + Id = 119L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + ProvinceId = 19L, + SortOrder = 1 + }, + new + { + Id = 120L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sanandaj", + NameFa = "سنندج", + ProvinceId = 20L, + SortOrder = 1 + }, + new + { + Id = 121L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khorramabad", + NameFa = "خرم‌آباد", + ProvinceId = 21L, + SortOrder = 1 + }, + new + { + Id = 122L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gorgan", + NameFa = "گرگان", + ProvinceId = 22L, + SortOrder = 1 + }, + new + { + Id = 123L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bandar Abbas", + NameFa = "بندرعباس", + ProvinceId = 23L, + SortOrder = 1 + }, + new + { + Id = 124L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + ProvinceId = 24L, + SortOrder = 1 + }, + new + { + Id = 125L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + ProvinceId = 25L, + SortOrder = 1 + }, + new + { + Id = 126L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + ProvinceId = 26L, + SortOrder = 1 + }, + new + { + Id = 127L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + ProvinceId = 27L, + SortOrder = 1 + }, + new + { + Id = 128L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zahedan", + NameFa = "زاهدان", + ProvinceId = 28L, + SortOrder = 1 + }, + new + { + Id = 129L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shahrekord", + NameFa = "شهرکرد", + ProvinceId = 29L, + SortOrder = 1 + }, + new + { + Id = 130L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yasuj", + NameFa = "یاسوج", + ProvinceId = 30L, + SortOrder = 1 + }, + new + { + Id = 131L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + ProvinceId = 31L, + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("CityId", "SortOrder"); + + b.ToTable("Districts", "geo"); + + b.HasData( + new + { + Id = 1001L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 1", + NameFa = "منطقه ۱", + SortOrder = 1 + }, + new + { + Id = 1002L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 2", + NameFa = "منطقه ۲", + SortOrder = 2 + }, + new + { + Id = 1003L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 3", + NameFa = "منطقه ۳", + SortOrder = 3 + }, + new + { + Id = 1004L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 4", + NameFa = "منطقه ۴", + SortOrder = 4 + }, + new + { + Id = 1005L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 5", + NameFa = "منطقه ۵", + SortOrder = 5 + }, + new + { + Id = 1006L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 6", + NameFa = "منطقه ۶", + SortOrder = 6 + }, + new + { + Id = 1007L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 7", + NameFa = "منطقه ۷", + SortOrder = 7 + }, + new + { + Id = 1008L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 8", + NameFa = "منطقه ۸", + SortOrder = 8 + }, + new + { + Id = 1009L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 9", + NameFa = "منطقه ۹", + SortOrder = 9 + }, + new + { + Id = 1010L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 10", + NameFa = "منطقه ۱۰", + SortOrder = 10 + }, + new + { + Id = 1011L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 11", + NameFa = "منطقه ۱۱", + SortOrder = 11 + }, + new + { + Id = 1012L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 12", + NameFa = "منطقه ۱۲", + SortOrder = 12 + }, + new + { + Id = 1013L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 13", + NameFa = "منطقه ۱۳", + SortOrder = 13 + }, + new + { + Id = 1014L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 14", + NameFa = "منطقه ۱۴", + SortOrder = 14 + }, + new + { + Id = 1015L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 15", + NameFa = "منطقه ۱۵", + SortOrder = 15 + }, + new + { + Id = 1016L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 16", + NameFa = "منطقه ۱۶", + SortOrder = 16 + }, + new + { + Id = 1017L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 17", + NameFa = "منطقه ۱۷", + SortOrder = 17 + }, + new + { + Id = 1018L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 18", + NameFa = "منطقه ۱۸", + SortOrder = 18 + }, + new + { + Id = 1019L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 19", + NameFa = "منطقه ۱۹", + SortOrder = 19 + }, + new + { + Id = 1020L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 20", + NameFa = "منطقه ۲۰", + SortOrder = 20 + }, + new + { + Id = 1021L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 21", + NameFa = "منطقه ۲۱", + SortOrder = 21 + }, + new + { + Id = 1022L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 22", + NameFa = "منطقه ۲۲", + SortOrder = 22 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("DistrictId"); + + b.HasIndex("NurseId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("NurseId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.ToTable("NurseServiceAreas", "geo"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("Provinces", "geo"); + + 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)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Alborz", + NameFa = "البرز", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Fars", + NameFa = "فارس", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Razavi Khorasan", + NameFa = "خراسان رضوی", + SortOrder = 5 + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "East Azerbaijan", + NameFa = "آذربایجان شرقی", + SortOrder = 6 + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "West Azerbaijan", + NameFa = "آذربایجان غربی", + SortOrder = 7 + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khuzestan", + NameFa = "خوزستان", + SortOrder = 8 + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + SortOrder = 9 + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + SortOrder = 10 + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gilan", + NameFa = "گیلان", + SortOrder = 11 + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mazandaran", + NameFa = "مازندران", + SortOrder = 12 + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Markazi", + NameFa = "مرکزی", + SortOrder = 13 + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + SortOrder = 14 + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + SortOrder = 15 + }, + new + { + Id = 16L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + SortOrder = 16 + }, + new + { + Id = 17L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "North Khorasan", + NameFa = "خراسان شمالی", + SortOrder = 17 + }, + new + { + Id = 18L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "South Khorasan", + NameFa = "خراسان جنوبی", + SortOrder = 18 + }, + new + { + Id = 19L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + SortOrder = 19 + }, + new + { + Id = 20L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kurdistan", + NameFa = "کردستان", + SortOrder = 20 + }, + new + { + Id = 21L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Lorestan", + NameFa = "لرستان", + SortOrder = 21 + }, + new + { + Id = 22L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Golestan", + NameFa = "گلستان", + SortOrder = 22 + }, + new + { + Id = 23L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hormozgan", + NameFa = "هرمزگان", + SortOrder = 23 + }, + new + { + Id = 24L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + SortOrder = 24 + }, + new + { + Id = 25L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + SortOrder = 25 + }, + new + { + Id = 26L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + SortOrder = 26 + }, + new + { + Id = 27L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + SortOrder = 27 + }, + new + { + Id = 28L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sistan and Baluchestan", + NameFa = "سیستان و بلوچستان", + SortOrder = 28 + }, + new + { + Id = 29L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chaharmahal and Bakhtiari", + NameFa = "چهارمحال و بختیاری", + SortOrder = 29 + }, + new + { + Id = 30L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kohgiluyeh and Boyer-Ahmad", + NameFa = "کهگیلویه و بویراحمد", + SortOrder = 30 + }, + new + { + Id = 31L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + SortOrder = 31 + }); + }); + + 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.CustomerAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddressLine") + .HasColumnType("nvarchar(max)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Latitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PostalCode") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("CustomerId") + .IsUnique() + .HasDatabaseName("UX_CustomerAddresses_Customer_Primary") + .HasFilter("[IsPrimary] = 1 AND [DeletedAt] IS NULL"); + + b.HasIndex("DistrictId"); + + b.ToTable("CustomerAddresses", "usr"); + }); + + 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.Verification.NurseCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CredentialNumber") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("CredentialType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExpiresAt") + .HasColumnType("date"); + + b.Property("HolderNameSnapshot") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IssuedAt") + .HasColumnType("date"); + + b.Property("IssuingAuthority") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("VerificationMethod") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VerificationSource") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VerifiedByAdminId"); + + b.HasIndex("NurseId", "CredentialType"); + + b.ToTable("NurseCredentials", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("InternalNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("RejectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ReviewedByAdminId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SubmittedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SuspendedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("NurseId") + .IsUnique(); + + b.HasIndex("ReviewedByAdminId"); + + b.ToTable("NurseVerifications", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IntegrityHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("ObjectStorageKey") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("OriginalFileName") + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("StepId") + .HasColumnType("bigint"); + + b.Property("UploadedByUserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StepId"); + + b.HasIndex("UploadedByUserId"); + + b.ToTable("VerificationDocuments", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalResponseJson") + .HasColumnType("nvarchar(max)"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseVerificationId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("StepTypeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("StepTypeId"); + + b.HasIndex("NurseVerificationId", "StepTypeId") + .IsUnique() + .HasDatabaseName("UX_VerificationSteps_Verification_StepType"); + + b.ToTable("VerificationSteps", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AutomationProvider") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("VerificationStepTypes", "verif"); + + b.HasData( + new + { + Id = 1L, + AutomationProvider = "identity_kyc_vendor", + Code = "identity_kyc", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "National-ID validity, name match and photo/video liveness via an Iranian e-KYC vendor.", + DisplayName = "Identity Verification (KYC)", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 1 + }, + new + { + Id = 2L, + AutomationProvider = "shahkar", + Code = "shahkar_match", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "Confirms the login SIM is registered to the nurse's own national ID (شاهکار).", + DisplayName = "Shahkar Phone Binding", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 2 + }, + new + { + Id = 3L, + Code = "moh_competency_license", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "پروانه صلاحیت حرفه‌ای — the MoH-mandated in-home nursing licence (bundles the criminal-record screen). Manual today.", + DisplayName = "MoH Professional Competency License", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 3 + }, + new + { + Id = 4L, + Code = "ino_membership", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "نظام پرستاری membership cross-check (ino.ir). Manual today.", + DisplayName = "Nursing Organization (INO) Membership", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 4 + }, + new + { + Id = 5L, + Code = "criminal_record", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "عدم سوء پیشینه — consent-gated, nurse-uploaded, time-limited (reverts on expiry).", + DisplayName = "Criminal Record Certificate", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 5 + }, + new + { + Id = 6L, + AutomationProvider = "sheba", + Code = "bank_account_verification", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "استعلام شبا — the payout IBAN owner's national ID must equal the verified nurse national ID.", + DisplayName = "Bank Account (IBAN) Ownership", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 6 + }); + }); + + 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.Catalog.NurseServiceVariant", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("Variants") + .HasForeignKey("ServiceCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Nurse"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany() + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionValue", "OptionValue") + .WithMany() + .HasForeignKey("OptionValueId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany("Options") + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + + b.Navigation("OptionValue"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("OptionGroups") + .HasForeignKey("ServiceCategoryId"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany("Values") + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.HasOne("Baya.Domain.Entities.Geography.Province", "Province") + .WithMany("Cities") + .HasForeignKey("ProvinceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Province"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany("Districts") + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.Navigation("City"); + + b.Navigation("Customer"); + + b.Navigation("District"); + }); + + 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.Verification.NurseCredential", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("VerifiedByAdminId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ReviewedByAdminId"); + + b.Navigation("Nurse"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.HasOne("Baya.Domain.Entities.Verification.VerificationStep", "Step") + .WithMany("Documents") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UploadedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.HasOne("Baya.Domain.Entities.Verification.NurseVerification", "NurseVerification") + .WithMany("Steps") + .HasForeignKey("NurseVerificationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Verification.VerificationStepType", "StepType") + .WithMany("Steps") + .HasForeignKey("StepTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("NurseVerification"); + + b.Navigation("StepType"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Navigation("OptionGroups"); + + b.Navigation("Variants"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Navigation("Districts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Navigation("Cities"); + }); + + 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"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Navigation("Documents"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702193432_SeedVerificationStepTypes.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702193432_SeedVerificationStepTypes.cs new file mode 100644 index 0000000..e3bdf44 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702193432_SeedVerificationStepTypes.cs @@ -0,0 +1,84 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace Baya.Infrastructure.Persistence.Migrations +{ + /// + public partial class SeedVerificationStepTypes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.InsertData( + schema: "verif", + table: "VerificationStepTypes", + columns: new[] { "Id", "AutomationProvider", "Code", "CreatedAt", "CreatedById", "Description", "DisplayName", "IsActive", "IsAutomated", "IsRequired", "ModifiedAt", "ModifiedById", "SortOrder" }, + values: new object[,] + { + { 1L, "identity_kyc_vendor", "identity_kyc", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "National-ID validity, name match and photo/video liveness via an Iranian e-KYC vendor.", "Identity Verification (KYC)", true, true, true, null, null, 1 }, + { 2L, "shahkar", "shahkar_match", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "Confirms the login SIM is registered to the nurse's own national ID (شاهکار).", "Shahkar Phone Binding", true, true, true, null, null, 2 } + }); + + migrationBuilder.InsertData( + schema: "verif", + table: "VerificationStepTypes", + columns: new[] { "Id", "AutomationProvider", "Code", "CreatedAt", "CreatedById", "Description", "DisplayName", "IsActive", "IsRequired", "ModifiedAt", "ModifiedById", "SortOrder" }, + values: new object[,] + { + { 3L, null, "moh_competency_license", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "پروانه صلاحیت حرفه‌ای — the MoH-mandated in-home nursing licence (bundles the criminal-record screen). Manual today.", "MoH Professional Competency License", true, true, null, null, 3 }, + { 4L, null, "ino_membership", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "نظام پرستاری membership cross-check (ino.ir). Manual today.", "Nursing Organization (INO) Membership", true, true, null, null, 4 }, + { 5L, null, "criminal_record", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "عدم سوء پیشینه — consent-gated, nurse-uploaded, time-limited (reverts on expiry).", "Criminal Record Certificate", true, true, null, null, 5 } + }); + + migrationBuilder.InsertData( + schema: "verif", + table: "VerificationStepTypes", + columns: new[] { "Id", "AutomationProvider", "Code", "CreatedAt", "CreatedById", "Description", "DisplayName", "IsActive", "IsAutomated", "IsRequired", "ModifiedAt", "ModifiedById", "SortOrder" }, + values: new object[] { 6L, "sheba", "bank_account_verification", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "استعلام شبا — the payout IBAN owner's national ID must equal the verified nurse national ID.", "Bank Account (IBAN) Ownership", true, true, true, null, null, 6 }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + schema: "verif", + table: "VerificationStepTypes", + keyColumn: "Id", + keyValue: 1L); + + migrationBuilder.DeleteData( + schema: "verif", + table: "VerificationStepTypes", + keyColumn: "Id", + keyValue: 2L); + + migrationBuilder.DeleteData( + schema: "verif", + table: "VerificationStepTypes", + keyColumn: "Id", + keyValue: 3L); + + migrationBuilder.DeleteData( + schema: "verif", + table: "VerificationStepTypes", + keyColumn: "Id", + keyValue: 4L); + + migrationBuilder.DeleteData( + schema: "verif", + table: "VerificationStepTypes", + keyColumn: "Id", + keyValue: 5L); + + migrationBuilder.DeleteData( + schema: "verif", + table: "VerificationStepTypes", + keyColumn: "Id", + keyValue: 6L); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index de6c114..158fb09 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -609,6 +609,15 @@ namespace Baya.Infrastructure.Persistence.Migrations Description = "Refresh-token session lifetime in days.", Key = "auth_session_ttl_days", Value = "30" + }, + new + { + Id = 16L, + 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 between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today).", + Key = "verification_expiry_scan_cadence_hours", + Value = "24" }); }); @@ -2560,6 +2569,411 @@ namespace Baya.Infrastructure.Persistence.Migrations b.ToTable("UserTokens", "usr"); }); + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CredentialNumber") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("CredentialType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExpiresAt") + .HasColumnType("date"); + + b.Property("HolderNameSnapshot") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IssuedAt") + .HasColumnType("date"); + + b.Property("IssuingAuthority") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("VerificationMethod") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VerificationSource") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VerifiedByAdminId"); + + b.HasIndex("NurseId", "CredentialType"); + + b.ToTable("NurseCredentials", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("InternalNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("RejectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ReviewedByAdminId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SubmittedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SuspendedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("NurseId") + .IsUnique(); + + b.HasIndex("ReviewedByAdminId"); + + b.ToTable("NurseVerifications", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IntegrityHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("ObjectStorageKey") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("OriginalFileName") + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("StepId") + .HasColumnType("bigint"); + + b.Property("UploadedByUserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StepId"); + + b.HasIndex("UploadedByUserId"); + + b.ToTable("VerificationDocuments", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalResponseJson") + .HasColumnType("nvarchar(max)"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseVerificationId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("StepTypeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("StepTypeId"); + + b.HasIndex("NurseVerificationId", "StepTypeId") + .IsUnique() + .HasDatabaseName("UX_VerificationSteps_Verification_StepType"); + + b.ToTable("VerificationSteps", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AutomationProvider") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("VerificationStepTypes", "verif"); + + b.HasData( + new + { + Id = 1L, + AutomationProvider = "identity_kyc_vendor", + Code = "identity_kyc", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "National-ID validity, name match and photo/video liveness via an Iranian e-KYC vendor.", + DisplayName = "Identity Verification (KYC)", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 1 + }, + new + { + Id = 2L, + AutomationProvider = "shahkar", + Code = "shahkar_match", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "Confirms the login SIM is registered to the nurse's own national ID (شاهکار).", + DisplayName = "Shahkar Phone Binding", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 2 + }, + new + { + Id = 3L, + Code = "moh_competency_license", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "پروانه صلاحیت حرفه‌ای — the MoH-mandated in-home nursing licence (bundles the criminal-record screen). Manual today.", + DisplayName = "MoH Professional Competency License", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 3 + }, + new + { + Id = 4L, + Code = "ino_membership", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "نظام پرستاری membership cross-check (ino.ir). Manual today.", + DisplayName = "Nursing Organization (INO) Membership", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 4 + }, + new + { + Id = 5L, + Code = "criminal_record", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "عدم سوء پیشینه — consent-gated, nurse-uploaded, time-limited (reverts on expiry).", + DisplayName = "Criminal Record Certificate", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 5 + }, + new + { + Id = 6L, + AutomationProvider = "sheba", + Code = "bank_account_verification", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "استعلام شبا — the payout IBAN owner's national ID must equal the verified nurse national ID.", + DisplayName = "Bank Account (IBAN) Ownership", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 6 + }); + }); + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => { b.HasOne("Baya.Domain.Entities.User.User", null) @@ -2863,6 +3277,70 @@ namespace Baya.Infrastructure.Persistence.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("VerifiedByAdminId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ReviewedByAdminId"); + + b.Navigation("Nurse"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.HasOne("Baya.Domain.Entities.Verification.VerificationStep", "Step") + .WithMany("Documents") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UploadedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.HasOne("Baya.Domain.Entities.Verification.NurseVerification", "NurseVerification") + .WithMany("Steps") + .HasForeignKey("NurseVerificationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Verification.VerificationStepType", "StepType") + .WithMany("Steps") + .HasForeignKey("StepTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("NurseVerification"); + + b.Navigation("StepType"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => { b.Navigation("Options"); @@ -2921,6 +3399,21 @@ namespace Baya.Infrastructure.Persistence.Migrations b.Navigation("UserRoles"); }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Navigation("Documents"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Navigation("Steps"); + }); #pragma warning restore 612, 618 } } 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 9e8dcfb..e25700c 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs @@ -18,6 +18,7 @@ public class UnitOfWork : IUnitOfWork public ICustomerAddressRepository CustomerAddressRepository { get; } public ICatalogRepository CatalogRepository { get; } public INurseServiceVariantRepository NurseServiceVariantRepository { get; } + public IVerificationRepository VerificationRepository { get; } public UnitOfWork(ApplicationDbContext db) { @@ -34,6 +35,7 @@ public class UnitOfWork : IUnitOfWork CustomerAddressRepository = new CustomerAddressRepository(_db); CatalogRepository = new CatalogRepository(_db); NurseServiceVariantRepository = new NurseServiceVariantRepository(_db); + VerificationRepository = new VerificationRepository(_db); } public Task CommitAsync() diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseBankAccountRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseBankAccountRepository.cs index 9dad7f6..fd087d5 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseBankAccountRepository.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseBankAccountRepository.cs @@ -42,6 +42,9 @@ internal sealed class NurseBankAccountRepository : BaseAsyncRepository HasAnyAsync(long nurseId, CancellationToken cancellationToken) => TableNoTracking.AnyAsync(a => a.NurseId == nurseId, cancellationToken); + public Task GetPrimaryAsync(long nurseId, CancellationToken cancellationToken) + => Table.FirstOrDefaultAsync(a => a.NurseId == nurseId && a.IsPrimary, 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. diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseProfileRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseProfileRepository.cs index 56a2681..0ca8557 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseProfileRepository.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseProfileRepository.cs @@ -15,6 +15,9 @@ internal sealed class NurseProfileRepository : BaseAsyncRepository public Task GetByUserIdAsync(int userId, CancellationToken cancellationToken) => Table.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken); + public Task GetTrackedByIdAsync(long nurseProfileId, CancellationToken cancellationToken) + => Table.FirstOrDefaultAsync(p => p.Id == nurseProfileId, cancellationToken); + public Task AddAsync(NurseProfile profile, CancellationToken cancellationToken) => base.AddAsync(profile); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/VerificationRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/VerificationRepository.cs new file mode 100644 index 0000000..48c0a41 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/VerificationRepository.cs @@ -0,0 +1,301 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Verification; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.User; +using Baya.Domain.Entities.Verification; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal sealed class VerificationRepository : BaseAsyncRepository, IVerificationRepository +{ + public VerificationRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + private IQueryable StepTypes => DbContext.Set(); + private IQueryable Steps => DbContext.Set(); + private IQueryable Documents => DbContext.Set(); + private IQueryable Credentials => DbContext.Set(); + private IQueryable Profiles => DbContext.Set(); + + // --- Step-type catalog --- + + public Task GetStepTypeByIdAsync(long id, CancellationToken cancellationToken) + => StepTypes.FirstOrDefaultAsync(t => t.Id == id, cancellationToken); + + public Task StepTypeCodeExistsAsync(string code, CancellationToken cancellationToken) + => StepTypes.AsNoTracking().AnyAsync(t => t.Code == code, cancellationToken); + + public Task StepTypeInUseAsync(long stepTypeId, CancellationToken cancellationToken) + => Steps.AsNoTracking().AnyAsync(s => s.StepTypeId == stepTypeId, cancellationToken); + + public async Task AddStepTypeAsync(VerificationStepType stepType, CancellationToken cancellationToken) + => await DbContext.Set().AddAsync(stepType, cancellationToken); + + public Task> ListStepTypesAsync(bool includeInactive, CancellationToken cancellationToken) + => ListStepTypesInternalAsync(includeInactive, cancellationToken); + + private async Task> ListStepTypesInternalAsync(bool includeInactive, CancellationToken cancellationToken) + { + var query = StepTypes.AsNoTracking(); + if (!includeInactive) + query = query.Where(t => t.IsActive); + + return await query + .OrderBy(t => t.SortOrder).ThenBy(t => t.Id) + .Select(t => new VerificationStepTypeDto( + t.Id, t.Code, t.DisplayName, t.Description, t.IsRequired, t.IsAutomated, t.AutomationProvider, t.SortOrder, t.IsActive)) + .ToListAsync(cancellationToken); + } + + public async Task> GetActiveRequiredStepTypesAsync(CancellationToken cancellationToken) + => await StepTypes.AsNoTracking() + .Where(t => t.IsActive && t.IsRequired) + .OrderBy(t => t.SortOrder) + .ToListAsync(cancellationToken); + + // --- Nurse verification aggregate (tracked, for mutation) --- + + public Task GetTrackedByNurseIdAsync(long nurseId, CancellationToken cancellationToken) + => Table + .Include(v => v.Steps).ThenInclude(s => s.StepType) + .FirstOrDefaultAsync(v => v.NurseId == nurseId, cancellationToken); + + public Task GetTrackedByIdAsync(long nurseVerificationId, CancellationToken cancellationToken) + => Table + .Include(v => v.Steps) + .FirstOrDefaultAsync(v => v.Id == nurseVerificationId, cancellationToken); + + public async Task AddVerificationAsync(NurseVerification verification, CancellationToken cancellationToken) + => await base.AddAsync(verification); + + public Task GetTrackedStepForNurseAsync(long stepId, long nurseId, CancellationToken cancellationToken) + => Steps + .Include(s => s.StepType) + .Include(s => s.NurseVerification).ThenInclude(v => v.Steps) + .FirstOrDefaultAsync(s => s.Id == stepId && s.NurseVerification.NurseId == nurseId, cancellationToken); + + public Task GetTrackedStepWithVerificationAsync(long stepId, CancellationToken cancellationToken) + => Steps + .Include(s => s.StepType) + .Include(s => s.NurseVerification).ThenInclude(v => v.Steps) + .FirstOrDefaultAsync(s => s.Id == stepId, cancellationToken); + + // --- Documents / credentials --- + + public async Task AddDocumentAsync(VerificationDocument document, CancellationToken cancellationToken) + => await DbContext.Set().AddAsync(document, cancellationToken); + + public async Task AddCredentialAsync(NurseCredential credential, CancellationToken cancellationToken) + => await DbContext.Set().AddAsync(credential, cancellationToken); + + // --- Projected reads --- + + public async Task GetStatusForNurseAsync(long nurseId, CancellationToken cancellationToken) + { + var header = await Table.AsNoTracking() + .Where(v => v.NurseId == nurseId) + .Select(v => new { v.Id, v.Status }) + .FirstOrDefaultAsync(cancellationToken); + + if (header is null) + return null; + + // Flat read + in-memory assembly: SQLite can't translate a nested collection projection. + var steps = await Steps.AsNoTracking() + .Where(s => s.NurseVerificationId == header.Id) + .OrderBy(s => s.StepType.SortOrder).ThenBy(s => s.Id) + .Select(s => new + { + s.Id, + Code = s.StepType.Code, + Display = s.StepType.DisplayName, + s.Status, + s.IsAutomated, + s.ExpiresAt, + s.FailureReason + }) + .ToListAsync(cancellationToken); + + var isBookable = await Profiles.AsNoTracking() + .Where(p => p.Id == nurseId) + .Select(p => p.IsVerified) + .FirstOrDefaultAsync(cancellationToken); + + var stepDtos = steps + .Select(s => new VerificationStepDto(s.Id, s.Code, s.Display, s.Status.ToCode(), s.IsAutomated, s.ExpiresAt, s.FailureReason)) + .ToList(); + + var blocking = steps + .Where(s => s.Status != VerificationStepStatus.Passed) + .Select(s => s.Code) + .ToList(); + + return new VerificationStatusDto(header.Status.ToCode(), isBookable, blocking, stepDtos); + } + + public async Task> ListPendingStepsAsync( + VerificationStepStatus? status, int page, int pageSize, Func signUrl, CancellationToken cancellationToken) + { + var effective = status ?? VerificationStepStatus.InReview; + + var query = Steps.AsNoTracking().Where(s => s.Status == effective); + + var total = await query.CountAsync(cancellationToken); + + var rows = await query + .OrderBy(s => s.NurseVerification.SubmittedAt).ThenBy(s => s.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(s => new + { + s.NurseVerificationId, + NurseId = s.NurseVerification.NurseId, + NurseName = (s.NurseVerification.Nurse.User.Name ?? "") + " " + (s.NurseVerification.Nurse.User.FamilyName ?? ""), + StepId = s.Id, + StepCode = s.StepType.Code, + StepDisplay = s.StepType.DisplayName, + s.Status, + SubmittedAt = s.NurseVerification.SubmittedAt + }) + .ToListAsync(cancellationToken); + + var stepIds = rows.Select(r => r.StepId).ToList(); + var docs = await LoadDocumentsAsync(stepIds, signUrl, cancellationToken); + + var items = rows + .Select(r => new AdminPendingStepDto( + r.NurseVerificationId, + r.NurseId, + r.NurseName.Trim(), + r.StepId, + r.StepCode, + r.StepDisplay, + r.Status.ToCode(), + r.SubmittedAt, + docs.TryGetValue(r.StepId, out var stepDocs) ? stepDocs : [])) + .ToList(); + + return new PagedResult(items, total, page, pageSize); + } + + public async Task GetDetailAsync(long nurseVerificationId, Func signUrl, CancellationToken cancellationToken) + { + var header = await Table.AsNoTracking() + .Where(v => v.Id == nurseVerificationId) + .Select(v => new + { + v.Id, + v.NurseId, + v.Status, + IdentityName = (v.Nurse.User.Name ?? "") + " " + (v.Nurse.User.FamilyName ?? "") + }) + .FirstOrDefaultAsync(cancellationToken); + + if (header is null) + return null; + + var steps = await Steps.AsNoTracking() + .Where(s => s.NurseVerificationId == header.Id) + .OrderBy(s => s.StepType.SortOrder).ThenBy(s => s.Id) + .Select(s => new + { + s.Id, + Code = s.StepType.Code, + Display = s.StepType.DisplayName, + s.Status, + s.IsAutomated, + s.ExpiresAt, + s.FailureReason + }) + .ToListAsync(cancellationToken); + + var stepIds = steps.Select(s => s.Id).ToList(); + var docs = await LoadDocumentsAsync(stepIds, signUrl, cancellationToken); + + var credentials = await Credentials.AsNoTracking() + .Where(c => c.NurseId == header.NurseId) + .OrderByDescending(c => c.Id) + .Select(c => new NurseCredentialDto( + c.Id, c.CredentialType, c.HolderNameSnapshot, c.IssuingAuthority, c.IssuedAt, c.ExpiresAt, c.VerificationMethod)) + .ToListAsync(cancellationToken); + + var stepDtos = steps + .Select(s => new AdminStepDetailDto( + s.Id, s.Code, s.Display, s.Status.ToCode(), s.IsAutomated, s.ExpiresAt, s.FailureReason, + docs.TryGetValue(s.Id, out var stepDocs) ? stepDocs : [])) + .ToList(); + + return new AdminVerificationDetailDto(header.Id, header.NurseId, header.IdentityName.Trim(), header.Status.ToCode(), stepDtos, credentials); + } + + public async Task GetTrustBadgeAsync(long nurseId, DateOnly today, CancellationToken cancellationToken) + { + var profile = await Profiles.AsNoTracking() + .Where(p => p.Id == nurseId) + .Select(p => new { p.IsVerified }) + .FirstOrDefaultAsync(cancellationToken); + + if (profile is null) + return null; + + var approvedAt = await Table.AsNoTracking() + .Where(v => v.NurseId == nurseId) + .Select(v => v.ApprovedAt) + .FirstOrDefaultAsync(cancellationToken); + + var credentialTypes = await Credentials.AsNoTracking() + .Where(c => c.NurseId == nurseId && (c.ExpiresAt == null || c.ExpiresAt >= today)) + .Select(c => c.CredentialType) + .Distinct() + .ToListAsync(cancellationToken); + + credentialTypes.Sort(StringComparer.Ordinal); + + return new TrustBadgeDto(nurseId, profile.IsVerified, approvedAt, credentialTypes); + } + + public Task GetNurseIdentityNameAsync(long nurseId, CancellationToken cancellationToken) + => Profiles.AsNoTracking() + .Where(p => p.Id == nurseId) + .Select(p => (p.User.Name ?? "") + " " + (p.User.FamilyName ?? "")) + .FirstOrDefaultAsync(cancellationToken); + + public Task GetTrackedUserAsync(int userId, CancellationToken cancellationToken) + => DbContext.Set().FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); + + public async Task> GetExpiredPassedStepsAsync( + DateTimeOffset asOf, int page, int pageSize, CancellationToken cancellationToken) + => await Steps.AsNoTracking() + .Where(s => s.Status == VerificationStepStatus.Passed && s.ExpiresAt != null && s.ExpiresAt < asOf) + .OrderBy(s => s.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(s => new ExpiringStepRow(s.Id, s.NurseVerificationId, s.NurseVerification.NurseId)) + .ToListAsync(cancellationToken); + + private async Task>> LoadDocumentsAsync( + IReadOnlyList stepIds, Func signUrl, CancellationToken cancellationToken) + { + if (stepIds.Count == 0) + return new Dictionary>(); + + var rows = await Documents.AsNoTracking() + .Where(d => stepIds.Contains(d.StepId)) + .OrderBy(d => d.Id) + .Select(d => new { d.Id, d.StepId, d.ContentType, d.FileSizeBytes, d.OriginalFileName, d.ObjectStorageKey }) + .ToListAsync(cancellationToken); + + return rows + .GroupBy(d => d.StepId) + .ToDictionary( + g => g.Key, + g => (IReadOnlyList)g + .Select(d => new VerificationDocumentDto(d.Id, d.ContentType, d.FileSizeBytes, d.OriginalFileName, signUrl(d.ObjectStorageKey))) + .ToList()); + } +} diff --git a/server/src/Tests/Baya.Test.Api/AdminVerificationApiTests.cs b/server/src/Tests/Baya.Test.Api/AdminVerificationApiTests.cs new file mode 100644 index 0000000..49ff854 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/AdminVerificationApiTests.cs @@ -0,0 +1,98 @@ +using System.Net; +using System.Net.Http.Json; +using System.Linq; + +namespace Baya.Test.Api; + +public class AdminVerificationApiTests(BayaApiFactory factory) : IClassFixture +{ + private static readonly string[] SeededCodes = + [ + "identity_kyc", "shahkar_match", "moh_competency_license", + "ino_membership", "criminal_record", "bank_account_verification" + ]; + + [Fact] + public async Task ListStepTypes_AsAdmin_ContainsTheSixSeededCodes() + { + var client = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, client, "09124200001"); + + var response = await client.GetAsync("/api/v1/admin_verification_step_types"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var codes = (await AuthTestClient.ReadDataAsync(response)).EnumerateArray() + .Select(s => s.GetProperty("code").GetString()) + .ToHashSet(); + foreach (var code in SeededCodes) + Assert.Contains(code, codes); + } + + [Fact] + public async Task UpsertStepType_NewRow_Persists_ProvesDataDriven() + { + var client = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, client, "09124200002"); + + var body = new + { + id = (long?)null, + code = "liability_insurance", + displayName = "Professional Liability Insurance", + description = (string?)null, + isRequired = false, + isAutomated = false, + automationProvider = (string?)null, + sortOrder = 7, + isActive = true + }; + var created = await client.PostAsJsonAsync("/api/v1/admin_verification_step_types", body); + Assert.Equal(HttpStatusCode.OK, created.StatusCode); + + var list = await client.GetAsync("/api/v1/admin_verification_step_types?includeInactive=true"); + var codes = (await AuthTestClient.ReadDataAsync(list)).EnumerateArray() + .Select(s => s.GetProperty("code").GetString()) + .ToList(); + Assert.Contains("liability_insurance", codes); + } + + [Fact] + public async Task UpsertStepType_InvalidCode_Returns400() + { + var client = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, client, "09124200003"); + + var body = new + { + id = (long?)null, + code = "Bad Code!", + displayName = "X", + description = (string?)null, + isRequired = false, + isAutomated = false, + automationProvider = (string?)null, + sortOrder = 8, + isActive = true + }; + var response = await client.PostAsJsonAsync("/api/v1/admin_verification_step_types", body); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task StepTypes_AsNurse_Returns403() + { + var client = factory.CreateClient(); + await ProfileTestClient.AuthenticateAsync(factory, client, "09124200004", "nurse"); + + var response = await client.GetAsync("/api/v1/admin_verification_step_types"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task StepTypes_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.GetAsync("/api/v1/admin_verification_step_types"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/server/src/Tests/Baya.Test.Api/NurseVerificationApiTests.cs b/server/src/Tests/Baya.Test.Api/NurseVerificationApiTests.cs new file mode 100644 index 0000000..8f37f85 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/NurseVerificationApiTests.cs @@ -0,0 +1,69 @@ +using System.Net; +using System.Net.Http.Json; +using System.Linq; + +namespace Baya.Test.Api; + +public class NurseVerificationApiTests(BayaApiFactory factory) : IClassFixture +{ + private static object NurseProfileBody => new + { + bio = "Experienced home nurse", + yearsOfExperience = 5, + educationLevel = "BSc", + educationField = "Nursing", + specializationsJson = "[]" + }; + + [Fact] + public async Task Submit_SeedsChecklist_ThenIdentityKycPasses() + { + var client = factory.CreateClient(); + await ProfileTestClient.AuthenticateAsync(factory, client, "09124100001", "nurse"); + await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert", NurseProfileBody); + + var submit = await client.PostAsJsonAsync("/api/v1/nurse_verification/submit", new { }); + Assert.Equal(HttpStatusCode.OK, submit.StatusCode); + var status = await AuthTestClient.ReadDataAsync(submit); + Assert.Equal("pending", status.GetProperty("status").GetString()); + Assert.False(status.GetProperty("isBookable").GetBoolean()); + var steps = status.GetProperty("steps").EnumerateArray().ToList(); + Assert.Equal(6, steps.Count); + // is_automated is snapshotted onto each step. + Assert.True(steps.Single(s => s.GetProperty("code").GetString() == "identity_kyc").GetProperty("isAutomated").GetBoolean()); + Assert.False(steps.Single(s => s.GetProperty("code").GetString() == "moh_competency_license").GetProperty("isAutomated").GetBoolean()); + + var run = await client.PostAsJsonAsync("/api/v1/nurse_verification/steps/identity_kyc/run", + new { nationalId = "0012345678", livenessPayload = (string?)null }); + Assert.Equal(HttpStatusCode.OK, run.StatusCode); + Assert.Equal("passed", (await AuthTestClient.ReadDataAsync(run)).GetProperty("stepStatus").GetString()); + + var get = await client.GetAsync("/api/v1/nurse_verification"); + var getData = await AuthTestClient.ReadDataAsync(get); + var identityStep = getData.GetProperty("steps").EnumerateArray() + .Single(s => s.GetProperty("code").GetString() == "identity_kyc"); + Assert.Equal("passed", identityStep.GetProperty("status").GetString()); + } + + [Fact] + public async Task RunIdentityKyc_InvalidNationalId_Returns400() + { + var client = factory.CreateClient(); + await ProfileTestClient.AuthenticateAsync(factory, client, "09124100002", "nurse"); + await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert", NurseProfileBody); + await client.PostAsJsonAsync("/api/v1/nurse_verification/submit", new { }); + + var run = await client.PostAsJsonAsync("/api/v1/nurse_verification/steps/identity_kyc/run", + new { nationalId = "123", livenessPayload = (string?)null }); + + Assert.Equal(HttpStatusCode.BadRequest, run.StatusCode); + } + + [Fact] + public async Task Get_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.GetAsync("/api/v1/nurse_verification"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/server/src/Tests/Baya.Test.Api/PublicTrustBadgeApiTests.cs b/server/src/Tests/Baya.Test.Api/PublicTrustBadgeApiTests.cs new file mode 100644 index 0000000..767da74 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/PublicTrustBadgeApiTests.cs @@ -0,0 +1,34 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class PublicTrustBadgeApiTests(BayaApiFactory factory) : IClassFixture +{ + [Fact] + public async Task TrustBadge_ExistingNurse_IsAnonymousAndShowsUnverified() + { + var client = factory.CreateClient(); + await ProfileTestClient.AuthenticateAsync(factory, client, "09124300001", "nurse"); + var upsert = await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert", + new { bio = "b", yearsOfExperience = 3, educationLevel = "", educationField = "", specializationsJson = "[]" }); + var nurseId = (await AuthTestClient.ReadDataAsync(upsert)).GetProperty("id").GetInt64(); + + // Anonymous client — the badge is public. + var anon = factory.CreateClient(); + var response = await anon.GetAsync($"/api/v1/nurses/{nurseId}/trust_badge"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var badge = await AuthTestClient.ReadDataAsync(response); + Assert.False(badge.GetProperty("isVerified").GetBoolean()); + Assert.Empty(badge.GetProperty("credentialTypes").EnumerateArray()); + } + + [Fact] + public async Task TrustBadge_UnknownNurse_Returns404() + { + var client = factory.CreateClient(); + var response = await client.GetAsync("/api/v1/nurses/999999/trust_badge"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Verification/AdminVerificationHandlersTests.cs b/server/src/Tests/Baya.Test.Foundation/Verification/AdminVerificationHandlersTests.cs new file mode 100644 index 0000000..3ac186f --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Verification/AdminVerificationHandlersTests.cs @@ -0,0 +1,206 @@ +using Baya.Application.Contracts.Audit; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.SupportAlerts; +using Baya.Application.Features.Verification.Commands.ReviewStep; +using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials; +using Baya.Application.Features.Verification.Commands.SuspendVerification; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.SupportAlerts; +using Baya.Domain.Entities.Verification; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using static Baya.Test.Foundation.Verification.VerificationTestSupport; + +namespace Baya.Test.Foundation.Verification; + +public class AdminVerificationHandlersTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero); + + private readonly ICurrentUser _currentUser = Substitute.For(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IVerificationRepository _verif = Substitute.For(); + private readonly INurseProfileRepository _nurses = Substitute.For(); + private readonly ICacheService _cache = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly IDateTimeProvider _clock = Substitute.For(); + + public AdminVerificationHandlersTests() + { + _currentUser.UserId.Returns(99); + _unitOfWork.VerificationRepository.Returns(_verif); + _unitOfWork.NurseProfileRepository.Returns(_nurses); + _clock.UtcNow.Returns(Now); + } + + private static (VerificationStep Step, NurseVerification Verification) MohStepInReview() + { + var step = Step(5, StepType(3, VerificationStepTypeCodes.MohCompetencyLicense, automated: false), VerificationStepStatus.InReview); + var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.InReview }; + verification.Steps.Add(step); + step.NurseVerification = verification; + return (step, verification); + } + + private AdminReviewStepCommandHandler ReviewHandler(ICredentialVerifier credentialVerifier) + => new(_currentUser, _unitOfWork, credentialVerifier, _audit, _cache, _clock); + + [Fact] + public async Task Review_ApproveCredentialStep_RecordsCredentialAndFlipsVerified() + { + var (step, _) = MohStepInReview(); + _verif.GetTrackedStepWithVerificationAsync(5, Arg.Any()).Returns(step); + _verif.GetNurseIdentityNameAsync(42, Arg.Any()).Returns("Ali Ahmadi"); + var profile = new NurseProfile(); + _nurses.GetTrackedByIdAsync(42, Arg.Any()).Returns(profile); + var credVerifier = Substitute.For(); + credVerifier.VerifyAsync(VerificationStepTypeCodes.MohCompetencyLicense, "LIC-1", Arg.Any()) + .Returns(new CredentialVerificationResult(CredentialVerificationStatus.RequiresManualReview, "manual", null)); + + var result = await ReviewHandler(credVerifier).Handle( + new AdminReviewStepCommand(5, true, null, "LIC-1", "Ali Ahmadi", "Ministry of Health", null, null, null), + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(VerificationStepStatus.Passed, step.Status); + Assert.True(profile.IsVerified); + await _verif.Received(1).AddCredentialAsync( + Arg.Is(c => + c.NurseId == 42 + && c.CredentialType == VerificationStepTypeCodes.MohCompetencyLicense + && c.HolderNameSnapshot == "Ali Ahmadi" + && c.VerificationMethod == "manual" + && c.VerifiedByAdminId == 99), + Arg.Any()); + await _audit.Received(1).WriteAsync("verification_step", "5", "approve", Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task Review_HolderNameMismatch_IsRejectedAndRecordsNoCredential() + { + var (step, _) = MohStepInReview(); + _verif.GetTrackedStepWithVerificationAsync(5, Arg.Any()).Returns(step); + _verif.GetNurseIdentityNameAsync(42, Arg.Any()).Returns("Ali Ahmadi"); + var credVerifier = Substitute.For(); + + var result = await ReviewHandler(credVerifier).Handle( + new AdminReviewStepCommand(5, true, null, "LIC-1", "Someone Else", "Ministry of Health", null, null, null), + CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(VerificationStepStatus.InReview, step.Status); + await _verif.DidNotReceive().AddCredentialAsync(Arg.Any(), Arg.Any()); + await _unitOfWork.DidNotReceive().CommitAsync(); + } + + [Fact] + public async Task Review_Reject_FailsStepWithReason() + { + var (step, verification) = MohStepInReview(); + _verif.GetTrackedStepWithVerificationAsync(5, Arg.Any()).Returns(step); + _nurses.GetTrackedByIdAsync(42, Arg.Any()).Returns(new NurseProfile()); + var credVerifier = Substitute.For(); + + var result = await ReviewHandler(credVerifier).Handle( + new AdminReviewStepCommand(5, false, "Document is illegible", null, null, null, null, null, null), + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(VerificationStepStatus.Failed, step.Status); + Assert.Equal("Document is illegible", step.FailureReason); + Assert.Equal("Document is illegible", verification.RejectionReason); + } + + [Fact] + public async Task Suspend_ReversesVerifiedInSameTransaction() + { + var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.Approved }; + verification.Steps.Add(Step(1, StepType(1, VerificationStepTypeCodes.IdentityKyc, automated: true), VerificationStepStatus.Passed)); + _verif.GetTrackedByIdAsync(10, Arg.Any()).Returns(verification); + var profile = new NurseProfile(); + profile.MarkVerified(); + _nurses.GetTrackedByIdAsync(42, Arg.Any()).Returns(profile); + var handler = new AdminSuspendVerificationCommandHandler(_currentUser, _unitOfWork, _audit, _cache, _clock); + + var result = await handler.Handle(new AdminSuspendVerificationCommand(10, "Fraud reported"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(VerificationStatus.Suspended, verification.Status); + Assert.Equal(Now, verification.SuspendedAt); + Assert.False(profile.IsVerified); + await _cache.Received(1).RemoveAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Scan_ExpiredCriminalRecord_RevertsStepRaisesAlertAndNotifies() + { + var bank = Step(4, StepType(4, VerificationStepTypeCodes.BankAccountVerification, automated: true), VerificationStepStatus.Passed); + var criminal = Step(5, StepType(5, VerificationStepTypeCodes.CriminalRecord, automated: false), VerificationStepStatus.Passed); + criminal.ExpiresAt = Now.AddDays(-1); + var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.Approved }; + verification.Steps.Add(bank); + verification.Steps.Add(criminal); + + _verif.GetExpiredPassedStepsAsync(Now, 1, 50, Arg.Any()) + .Returns(new List { new(5, 10, 42) }); + _verif.GetTrackedByIdAsync(10, Arg.Any()).Returns(verification); + var profile = new NurseProfile { UserId = 7 }; + profile.MarkVerified(); + _nurses.GetTrackedByIdAsync(42, Arg.Any()).Returns(profile); + + var alerts = Substitute.For(); + var notifications = Substitute.For(); + var handler = new ScanExpiringCredentialsCommandHandler(_unitOfWork, alerts, notifications, _cache, _clock); + + var result = await handler.Handle(new ScanExpiringCredentialsCommand(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(1, result.Result.RevertedNurses); + Assert.Equal(VerificationStepStatus.Expired, criminal.Status); + Assert.False(profile.IsVerified); + await alerts.Received(1).RaiseAsync( + SupportAlertType.VerificationExpired, "nurse_profile", "42", SupportAlertSeverity.Medium, + Arg.Any(), Arg.Any(), Arg.Any()); + await notifications.Received(1).DispatchAsync( + Arg.Is(n => n.RecipientUserId == 7 && n.Type == "verification_expiry_prompt"), + Arg.Any()); + } + + [Fact] + public async Task Scan_NullProfileNurse_LeavesNoDirtyStepForNextNursesCommit() + { + // Nurse A's profile is missing (e.g. soft-deleted) while their verification still has an expired + // step; nurse B is valid. The guard must run before mutating A's step, so A's step is never left + // dirty for B's commit to flush without the atomic re-gate. + var stepA = Step(5, StepType(5, VerificationStepTypeCodes.CriminalRecord, automated: false), VerificationStepStatus.Passed); + stepA.ExpiresAt = Now.AddDays(-1); + var verificationA = new NurseVerification { NurseId = 42, Status = VerificationStatus.Approved }; + verificationA.Steps.Add(stepA); + + var stepB = Step(6, StepType(5, VerificationStepTypeCodes.CriminalRecord, automated: false), VerificationStepStatus.Passed); + stepB.ExpiresAt = Now.AddDays(-1); + var verificationB = new NurseVerification { NurseId = 99, Status = VerificationStatus.Approved }; + verificationB.Steps.Add(stepB); + + _verif.GetExpiredPassedStepsAsync(Now, 1, 50, Arg.Any()) + .Returns(new List { new(5, 10, 42), new(6, 20, 99) }); + _verif.GetTrackedByIdAsync(10, Arg.Any()).Returns(verificationA); + _verif.GetTrackedByIdAsync(20, Arg.Any()).Returns(verificationB); + _nurses.GetTrackedByIdAsync(42, Arg.Any()).ReturnsNull(); + var profileB = new NurseProfile { UserId = 8 }; + profileB.MarkVerified(); + _nurses.GetTrackedByIdAsync(99, Arg.Any()).Returns(profileB); + + var handler = new ScanExpiringCredentialsCommandHandler( + _unitOfWork, Substitute.For(), Substitute.For(), _cache, _clock); + + var result = await handler.Handle(new ScanExpiringCredentialsCommand(), CancellationToken.None); + + Assert.Equal(1, result.Result.RevertedNurses); + Assert.Equal(VerificationStepStatus.Passed, stepA.Status); // never mutated + Assert.Equal(VerificationStepStatus.Expired, stepB.Status); + Assert.False(profileB.IsVerified); + await _unitOfWork.Received(1).CommitAsync(); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Verification/RunStepHandlersTests.cs b/server/src/Tests/Baya.Test.Foundation/Verification/RunStepHandlersTests.cs new file mode 100644 index 0000000..7ce8334 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Verification/RunStepHandlersTests.cs @@ -0,0 +1,146 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.SupportAlerts; +using Baya.Application.Features.Verification.Commands.RunBankAccountVerification; +using Baya.Application.Features.Verification.Commands.RunIdentityKyc; +using Baya.Application.Features.Verification.Commands.RunShahkarMatch; +using Baya.Application.Models.Identity; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.SupportAlerts; +using Baya.Domain.Entities.User; +using Baya.Domain.Entities.Verification; +using NSubstitute; +using static Baya.Test.Foundation.Verification.VerificationTestSupport; + +namespace Baya.Test.Foundation.Verification; + +public class RunStepHandlersTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero); + + private readonly ICurrentUser _currentUser = Substitute.For(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IVerificationRepository _verif = Substitute.For(); + private readonly INurseProfileRepository _nurses = Substitute.For(); + private readonly INurseBankAccountRepository _accounts = Substitute.For(); + private readonly IDateTimeProvider _clock = Substitute.For(); + + public RunStepHandlersTests() + { + _currentUser.UserId.Returns(7); + _currentUser.Roles.Returns([RoleNames.Nurse]); + _unitOfWork.VerificationRepository.Returns(_verif); + _unitOfWork.NurseProfileRepository.Returns(_nurses); + _unitOfWork.NurseBankAccountRepository.Returns(_accounts); + _clock.UtcNow.Returns(Now); + _nurses.GetProfileIdByUserIdAsync(7, Arg.Any()).Returns(42L); + _nurses.GetTrackedByIdAsync(42, Arg.Any()).Returns(new NurseProfile()); + } + + private static NurseVerification VerificationWith(VerificationStep step) + { + var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.Pending }; + verification.Steps.Add(step); + return verification; + } + + [Fact] + public async Task RunIdentityKyc_Pass_PopulatesNationalIdAndPassesStep() + { + var step = Step(1, StepType(1, VerificationStepTypeCodes.IdentityKyc, automated: true), VerificationStepStatus.Pending); + _verif.GetTrackedByNurseIdAsync(42, Arg.Any()).Returns(VerificationWith(step)); + var user = new User(); + _verif.GetTrackedUserAsync(7, Arg.Any()).Returns(user); + var identityKyc = Substitute.For(); + identityKyc.VerifyAsync("0012345678", null, Arg.Any()) + .Returns(new IdentityKycResult(true, "Verified Nurse", "ref", "{}", null)); + var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock); + + var result = await handler.Handle(new RunIdentityKycCommand("0012345678", null), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(VerificationStepStatus.Passed, step.Status); + Assert.Equal("0012345678", user.NationalId); + Assert.Equal(Now, user.NationalIdVerifiedAt); + await _unitOfWork.Received(1).CommitAsync(); + } + + [Fact] + public async Task RunIdentityKyc_Fail_MarksStepFailedAndLeavesNationalIdNull() + { + var step = Step(1, StepType(1, VerificationStepTypeCodes.IdentityKyc, automated: true), VerificationStepStatus.Pending); + _verif.GetTrackedByNurseIdAsync(42, Arg.Any()).Returns(VerificationWith(step)); + var user = new User(); + _verif.GetTrackedUserAsync(7, Arg.Any()).Returns(user); + var identityKyc = Substitute.For(); + identityKyc.VerifyAsync("0000000000", null, Arg.Any()) + .Returns(new IdentityKycResult(false, null, "ref", "{}", "could not verify")); + var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock); + + var result = await handler.Handle(new RunIdentityKycCommand("0000000000", null), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(VerificationStepStatus.Failed, step.Status); + Assert.Equal("could not verify", step.FailureReason); + Assert.Null(user.NationalId); + } + + [Fact] + public async Task RunShahkar_SharedSim_FailsStepAndRaisesAlert() + { + var step = Step(1, StepType(1, VerificationStepTypeCodes.ShahkarMatch, automated: true), VerificationStepStatus.Pending); + _verif.GetTrackedByNurseIdAsync(42, Arg.Any()).Returns(VerificationWith(step)); + var user = new User { PhoneNumber = "09120000000", NationalId = "0012345678" }; + _verif.GetTrackedUserAsync(7, Arg.Any()).Returns(user); + var shahkar = Substitute.For(); + shahkar.MatchAsync("09120000000", "0012345678", Arg.Any()) + .Returns(new ShahkarMatchResult(false, true, "ref", "{}", "shared sim")); + var alerts = Substitute.For(); + var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock); + + var result = await handler.Handle(new RunShahkarMatchCommand(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(VerificationStepStatus.Failed, step.Status); + await alerts.Received(1).RaiseAsync( + SupportAlertType.SharedSim, "nurse_profile", "42", SupportAlertSeverity.High, + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task RunShahkar_BeforeIdentityKyc_IsRejected() + { + var step = Step(1, StepType(1, VerificationStepTypeCodes.ShahkarMatch, automated: true), VerificationStepStatus.Pending); + _verif.GetTrackedByNurseIdAsync(42, Arg.Any()).Returns(VerificationWith(step)); + _verif.GetTrackedUserAsync(7, Arg.Any()).Returns(new User { PhoneNumber = "09121112233" }); + var shahkar = Substitute.For(); + var alerts = Substitute.For(); + var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock); + + var result = await handler.Handle(new RunShahkarMatchCommand(), CancellationToken.None); + + Assert.False(result.IsSuccess); + await shahkar.DidNotReceive().MatchAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task RunBankAccount_Mismatch_FailsStepAndRecordsMismatch() + { + _nurses.GetIdentityContextByUserIdAsync(7, Arg.Any()) + .Returns(new NurseIdentityContext(42, "0012345678")); + var step = Step(1, StepType(1, VerificationStepTypeCodes.BankAccountVerification, automated: true), VerificationStepStatus.Pending); + _verif.GetTrackedByNurseIdAsync(42, Arg.Any()).Returns(VerificationWith(step)); + var account = new NurseBankAccount { NurseId = 42, Iban = "IR000000000000000000000000" }; + _accounts.GetPrimaryAsync(42, Arg.Any()).Returns(account); + var verifier = Substitute.For(); + verifier.VerifyOwnershipAsync(account.Iban, "0012345678", Arg.Any()) + .Returns(new OwnershipInquiryResult(false, "Someone Else", "ref")); + var handler = new RunBankAccountVerificationCommandHandler(_currentUser, _unitOfWork, verifier, _clock); + + var result = await handler.Handle(new RunBankAccountVerificationCommand(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(VerificationStepStatus.Failed, step.Status); + Assert.False(account.MatchedNationalId); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Verification/SubmitNurseVerificationHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Verification/SubmitNurseVerificationHandlerTests.cs new file mode 100644 index 0000000..09d049a --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Verification/SubmitNurseVerificationHandlerTests.cs @@ -0,0 +1,90 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Features.Verification.Commands.SubmitVerification; +using Baya.Application.Models.Identity; +using Baya.Application.Models.Verification; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.User; +using Baya.Domain.Entities.Verification; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using static Baya.Test.Foundation.Verification.VerificationTestSupport; + +namespace Baya.Test.Foundation.Verification; + +public class SubmitNurseVerificationHandlerTests +{ + private readonly ICurrentUser _currentUser = Substitute.For(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IVerificationRepository _verif = Substitute.For(); + private readonly INurseProfileRepository _nurses = Substitute.For(); + private readonly IDateTimeProvider _clock = Substitute.For(); + + public SubmitNurseVerificationHandlerTests() + { + _currentUser.UserId.Returns(7); + _currentUser.Roles.Returns([RoleNames.Nurse]); + _unitOfWork.VerificationRepository.Returns(_verif); + _unitOfWork.NurseProfileRepository.Returns(_nurses); + _clock.UtcNow.Returns(new DateTimeOffset(2026, 7, 1, 12, 0, 0, TimeSpan.Zero)); + _nurses.GetIdentityContextByUserIdAsync(7, Arg.Any()) + .Returns(new NurseIdentityContext(42, "0012345678")); + _nurses.GetTrackedByIdAsync(42, Arg.Any()).Returns(new NurseProfile()); + _verif.GetActiveRequiredStepTypesAsync(Arg.Any()) + .Returns(new List + { + StepType(1, VerificationStepTypeCodes.IdentityKyc, automated: true), + StepType(2, VerificationStepTypeCodes.MohCompetencyLicense, automated: false) + }); + _verif.GetStatusForNurseAsync(42, Arg.Any()) + .Returns(new VerificationStatusDto("pending", false, ["identity_kyc", "moh_competency_license"], [])); + } + + private SubmitNurseVerificationCommandHandler Handler() => new(_currentUser, _unitOfWork, _clock); + + [Fact] + public async Task Submit_NewVerification_SeedsOneStepPerRequiredType_WithAutomationSnapshot() + { + _verif.GetTrackedByNurseIdAsync(42, Arg.Any()).ReturnsNull(); + + var result = await Handler().Handle(new SubmitNurseVerificationCommand(), CancellationToken.None); + + Assert.True(result.IsSuccess); + await _verif.Received(1).AddVerificationAsync( + Arg.Is(v => + v.NurseId == 42 + && v.Status == VerificationStatus.Pending + && v.Steps.Count == 2 + && v.Steps.Any(s => s.StepTypeId == 1 && s.IsAutomated) + && v.Steps.Any(s => s.StepTypeId == 2 && !s.IsAutomated)), + Arg.Any()); + await _unitOfWork.Received(1).CommitAsync(); + } + + [Fact] + public async Task Submit_ExistingVerification_IsIdempotent_OnlyAddsMissingSteps() + { + var existing = new NurseVerification { NurseId = 42, Status = VerificationStatus.Pending, SubmittedAt = _clock.UtcNow }; + existing.Steps.Add(Step(1, StepType(1, VerificationStepTypeCodes.IdentityKyc, automated: true), VerificationStepStatus.Pending)); + _verif.GetTrackedByNurseIdAsync(42, Arg.Any()).Returns(existing); + + var result = await Handler().Handle(new SubmitNurseVerificationCommand(), CancellationToken.None); + + Assert.True(result.IsSuccess); + // Only the missing MoH step is added; the identity step is not duplicated. + Assert.Equal(2, existing.Steps.Count); + Assert.Single(existing.Steps, s => s.StepTypeId == 2); + await _verif.DidNotReceive().AddVerificationAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Submit_NonNurse_IsForbidden() + { + _currentUser.Roles.Returns([RoleNames.Customer]); + + var result = await Handler().Handle(new SubmitNurseVerificationCommand(), CancellationToken.None); + + Assert.True(result.IsForbidden); + await _unitOfWork.DidNotReceive().CommitAsync(); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Verification/VerificationAggregatorTests.cs b/server/src/Tests/Baya.Test.Foundation/Verification/VerificationAggregatorTests.cs new file mode 100644 index 0000000..b69f19d --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Verification/VerificationAggregatorTests.cs @@ -0,0 +1,120 @@ +using Baya.Application.Common; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Verification; +using static Baya.Test.Foundation.Verification.VerificationTestSupport; + +namespace Baya.Test.Foundation.Verification; + +public class VerificationAggregatorTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero); + + private static (NurseVerification Verification, NurseProfile Profile) Build(params VerificationStepStatus[] statuses) + { + var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.Pending }; + for (var i = 0; i < statuses.Length; i++) + verification.Steps.Add(Step(i + 1, StepType(i + 1, $"step_{i}", automated: false), statuses[i])); + + return (verification, new NurseProfile()); + } + + [Fact] + public void Finalize_AllPassed_ApprovesAndFlipsVerified() + { + var (verification, profile) = Build(VerificationStepStatus.Passed, VerificationStepStatus.Passed); + + var status = VerificationAggregator.Finalize(verification, profile, Now); + + Assert.Equal(VerificationStatus.Approved, status); + Assert.Equal(VerificationStatus.Approved, verification.Status); + Assert.Equal(Now, verification.ApprovedAt); + Assert.True(profile.IsVerified); + } + + [Fact] + public void Finalize_OneFailed_RejectsAndKeepsUnverified() + { + var (verification, profile) = Build(VerificationStepStatus.Passed, VerificationStepStatus.Failed); + + VerificationAggregator.Finalize(verification, profile, Now); + + Assert.Equal(VerificationStatus.Rejected, verification.Status); + Assert.Equal(Now, verification.RejectedAt); + Assert.Null(verification.ApprovedAt); + Assert.False(profile.IsVerified); + } + + [Fact] + public void Finalize_OneInReview_SetsInReview() + { + var (verification, profile) = Build(VerificationStepStatus.Passed, VerificationStepStatus.InReview); + + VerificationAggregator.Finalize(verification, profile, Now); + + Assert.Equal(VerificationStatus.InReview, verification.Status); + Assert.False(profile.IsVerified); + } + + [Fact] + public void Finalize_AllPending_StaysPending() + { + var (verification, profile) = Build(VerificationStepStatus.Pending, VerificationStepStatus.Pending); + + VerificationAggregator.Finalize(verification, profile, Now); + + Assert.Equal(VerificationStatus.Pending, verification.Status); + Assert.False(profile.IsVerified); + } + + [Fact] + public void Finalize_PreviouslyApproved_ThenExpires_ReversesVerified() + { + var (verification, profile) = Build(VerificationStepStatus.Passed, VerificationStepStatus.Passed); + VerificationAggregator.Finalize(verification, profile, Now); + Assert.True(profile.IsVerified); + + // A required step lapses (the expiry scan) → re-gate. + verification.Steps.Last().Status = VerificationStepStatus.Expired; + VerificationAggregator.Finalize(verification, profile, Now); + + Assert.False(profile.IsVerified); + Assert.Equal(VerificationStatus.Pending, verification.Status); + Assert.Null(verification.ApprovedAt); + } + + [Fact] + public void Finalize_Suspended_StaysSuspendedAndUnverified() + { + var (verification, profile) = Build(VerificationStepStatus.Passed, VerificationStepStatus.Passed); + profile.MarkVerified(); + verification.Status = VerificationStatus.Suspended; + + var status = VerificationAggregator.Finalize(verification, profile, Now); + + Assert.Equal(VerificationStatus.Suspended, status); + Assert.False(profile.IsVerified); + } + + [Fact] + public void Finalize_NoSteps_DoesNotApprove() + { + var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.Pending }; + var profile = new NurseProfile(); + + VerificationAggregator.Finalize(verification, profile, Now); + + Assert.False(profile.IsVerified); + Assert.Equal(VerificationStatus.Pending, verification.Status); + } + + [Fact] + public void BlockingStepCodes_ReturnsCodesOfNonPassedSteps() + { + var (verification, _) = Build(VerificationStepStatus.Passed, VerificationStepStatus.Pending); + + var blocking = VerificationAggregator.BlockingStepCodes(verification.Steps); + + Assert.Single(blocking); + Assert.Equal("step_1", blocking[0]); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Verification/VerificationTestSupport.cs b/server/src/Tests/Baya.Test.Foundation/Verification/VerificationTestSupport.cs new file mode 100644 index 0000000..dc046cd --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Verification/VerificationTestSupport.cs @@ -0,0 +1,38 @@ +using Baya.Domain.Entities.Verification; + +namespace Baya.Test.Foundation.Verification; + +/// +/// Helpers for the verification handler/aggregator tests. Entity ids have a protected setter (they are +/// assigned by EF on save); these set them via reflection so a mocked repository can return graphs with +/// stable ids without a real DbContext. +/// +internal static class VerificationTestSupport +{ + public static T WithId(this T entity, long id) + { + var setter = typeof(T).GetProperty("Id")!.GetSetMethod(nonPublic: true)!; + setter.Invoke(entity, [id]); + return entity; + } + + public static VerificationStepType StepType(long id, string code, bool automated, bool required = true) + => new VerificationStepType + { + Code = code, + DisplayName = code, + IsAutomated = automated, + IsRequired = required, + IsActive = true, + SortOrder = (int)id + }.WithId(id); + + public static VerificationStep Step(long id, VerificationStepType type, VerificationStepStatus status) + => new VerificationStep + { + StepTypeId = type.Id, + StepType = type, + Status = status, + IsAutomated = type.IsAutomated + }.WithId(id); +}