backend phase 6: nurse verification & credentials (mocked vendors)

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 <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-05 14:39:32 +03:30
parent 687fbfc6d9
commit 1c266523bc
105 changed files with 13938 additions and 10 deletions
+257
View File
@@ -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<T>` 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<VerificationStepTypeDto>`. **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<AdminPendingStepDto>`. 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<T>`: `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": "<verified identity name>", "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.
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,34 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
- **Notes for frontend:** <anything load-bearing>
-->
## 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) /
@@ -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**).
@@ -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: "<verified identity name>", 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.
@@ -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 | 🟡 |
@@ -43,6 +43,14 @@
<li><strong>MVP:</strong> all six steps; data-driven <code>verification_step_types</code>; structured <code>nurse_credentials</code> registry; manual MoH/INO verification; nurse-uploaded عدم سوء پیشینه with expiry; automated identity + Shahkar + IBAN-ownership via one KYC vendor; expiry-driven re-verification alerts; transactional <code>is_verified</code>.</li>
<li><strong>DEFERRED:</strong> automated MoH/INO license lookup (pending a B2B API); ML-driven fraud scoring (<code>fraud_flags</code> is modeled but inactive); professional-liability-insurance step (addable as a row when required).</li>
</ul>
<h2 id="c-bis-as-built-clarifications-backend-phase-6">(c-bis) As-built clarifications (backend-phase-6) <a class="anchor" href="#c-bis-as-built-clarifications-backend-phase-6" aria-hidden="true">#</a></h2>
<p>Rules confirmed while building the pipeline (implementation in the <code>verif</code> schema):</p>
<ul>
<li><strong>Step state <code>pending</code> vs <code>in_review</code>:</strong> <code>pending</code> = awaiting submission/automation (a seeded or automated step not yet run); <code>in_review</code> = a manual step with evidence uploaded, awaiting the admin decision. The aggregate is <code>in_review</code> while any required step is <code>in_review</code>, <code>rejected</code> if any required step failed, and <code>approved</code> only when <strong>every</strong> required step is <code>passed</code> (the same transaction flips <code>is_verified</code>).</li>
<li><strong>Renewal is an in-app notification, not only an alert:</strong> an expiry re-scan raises a <code>verification_expired</code> <strong>support alert</strong> (staff worklist) <em>and</em> sends the nurse a <code>verification_expiry_prompt</code> <strong>notification</strong>; the required step reverts to <code>expired</code> and bookability is re-gated.</li>
<li><strong>The public trust badge exposes credential _types_ held, never the numbers</strong> (e.g. "MoH license · INO member"); <code>credential_number</code> is encrypted and never serialized.</li>
<li><strong>Shared-SIM</strong> is surfaced as a distinct <code>shared_sim</code> support-alert type (non-accusatory), separate from a plain phone↔national-id mismatch.</li>
</ul>
<h2 id="d-supporting-database-entities">(d) Supporting database entities <a class="anchor" href="#d-supporting-database-entities" aria-hidden="true">#</a></h2>
<p><code>nurse_verifications</code>, <code>verification_step_types</code>, <code>verification_steps</code>, <code>verification_documents</code>, <strong><code>nurse_credentials</code></strong> (structured license registry), <code>nurse_bank_accounts</code> (IBAN ownership), <code>support_alerts</code> (expiry/renewal), <code>audit_logs</code>.</p>
<blockquote><p><strong>Related:</strong> Data model — <a href="../data-model/04-verification-and-credentials.html">Verification &amp; Credentials</a>; Research — <a href="../research/verification.html">Verification</a>.</p>
@@ -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`.
+6 -5
View File
@@ -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
+20
View File
@@ -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
@@ -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<IReadOnlyList<VerificationStepTypeDto>>]
public async Task<IActionResult> List([FromQuery] bool includeInactive, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new AdminListStepTypesQuery(includeInactive), cancellationToken));
[HttpPost]
[ProducesOkApiResponseType<VerificationStepTypeDto>]
public async Task<IActionResult> Upsert(AdminUpsertStepTypeCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpDelete("{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> Deactivate(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new AdminDeactivateStepTypeCommand(id), cancellationToken));
}
@@ -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<PagedResult<AdminPendingStepDto>>]
public async Task<IActionResult> List([FromQuery] AdminListPendingStepsQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
[HttpGet("{nurseVerificationId}")]
[ProducesOkApiResponseType<AdminVerificationDetailDto>]
public async Task<IActionResult> Get(long nurseVerificationId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new AdminGetVerificationDetailQuery(nurseVerificationId), cancellationToken));
[HttpPost("steps/{stepId}/decide")]
[ProducesOkApiResponseType<ReviewStepResult>]
public async Task<IActionResult> Decide(long stepId, AdminReviewStepCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { StepId = stepId }, cancellationToken));
[HttpPost("{nurseVerificationId}/suspend")]
[ProducesOkApiResponseType]
public async Task<IActionResult> Suspend(long nurseVerificationId, AdminSuspendVerificationCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { NurseVerificationId = nurseVerificationId }, cancellationToken));
[HttpPost("scan_expiring")]
[ProducesOkApiResponseType<ScanExpiringResult>]
public async Task<IActionResult> ScanExpiring(ScanExpiringCredentialsCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
}
@@ -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<VerificationStatusDto>]
public async Task<IActionResult> Submit(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new SubmitNurseVerificationCommand(), cancellationToken));
[HttpGet]
[ProducesOkApiResponseType<VerificationStatusDto>]
public async Task<IActionResult> Get(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetNurseVerificationStatusQuery(), cancellationToken));
[HttpPost("steps/{stepId}/[action]")]
[ProducesOkApiResponseType<UploadUrlResult>]
public async Task<IActionResult> UploadUrl(long stepId, RequestDocumentUploadUrlCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { StepId = stepId }, cancellationToken));
[HttpPost("steps/{stepId}/documents")]
[ProducesOkApiResponseType<DocumentConfirmedResult>]
public async Task<IActionResult> ConfirmDocument(long stepId, ConfirmDocumentUploadCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { StepId = stepId }, cancellationToken));
[HttpPost("steps/identity_kyc/run")]
[ProducesOkApiResponseType<RunStepResult>]
public async Task<IActionResult> RunIdentityKyc(RunIdentityKycCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("steps/shahkar_match/run")]
[ProducesOkApiResponseType<RunStepResult>]
public async Task<IActionResult> RunShahkarMatch(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new RunShahkarMatchCommand(), cancellationToken));
[HttpPost("steps/bank_account_verification/run")]
[ProducesOkApiResponseType<RunStepResult>]
public async Task<IActionResult> RunBankAccountVerification(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new RunBankAccountVerificationCommand(), cancellationToken));
}
@@ -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<TrustBadgeDto>]
public async Task<IActionResult> TrustBadge(long nurseId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetVerifiedTrustBadgeQuery(nurseId), cancellationToken));
}
@@ -0,0 +1,45 @@
#nullable enable
using System.Globalization;
namespace Baya.Application.Common;
/// <summary>
/// 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 &amp; 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).
/// </summary>
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<string> Tokenize(string? value)
{
var set = new HashSet<string>(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);
}
@@ -0,0 +1,77 @@
#nullable enable
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Verification;
namespace Baya.Application.Common;
/// <summary>
/// The single place that rolls per-step outcomes into <see cref="NurseVerification.Status"/> and drives the
/// guarded <c>nurse_profiles.is_verified</c> flip. It mutates the <b>tracked</b> verification + profile
/// in place; the caller commits once, so the status change and the <c>is_verified</c> 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.
/// </summary>
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;
}
/// <summary>The codes of required steps not yet passed — the "what's blocking bookability" summary.</summary>
public static IReadOnlyList<string> BlockingStepCodes(IEnumerable<VerificationStep> steps)
=> steps
.Where(s => s.Status != VerificationStepStatus.Passed)
.Select(s => s.StepType?.Code ?? string.Empty)
.Where(c => c.Length > 0)
.ToList();
}
@@ -0,0 +1,32 @@
#nullable enable
namespace Baya.Application.Contracts.Common;
/// <summary>
/// Seam for verifying a professional credential (MoH پروانه صلاحیت حرفه‌ای, INO membership,
/// عدم سوء پیشینه) against its authoritative source. There is <b>no public B2B API</b> for MoH/INO today,
/// so the default implementation returns <see cref="CredentialVerificationStatus.RequiresManualReview"/>
/// (<c>verification_method = manual</c>) — the admin verifies the uploaded document against the official
/// portal. The interface is shaped so an <c>api</c>/<c>portal</c> implementation drops in later without
/// touching callers, keeping the audit-defensible <c>verification_method</c> honest.
/// </summary>
public interface ICredentialVerifier
{
Task<CredentialVerificationResult> VerifyAsync(string credentialType, string? credentialNumber, CancellationToken cancellationToken = default);
}
/// <summary>Whether a credential can be verified automatically or needs a manual admin decision.</summary>
public enum CredentialVerificationStatus
{
RequiresManualReview,
Verified,
Failed
}
/// <summary>Outcome of an <see cref="ICredentialVerifier"/> check.</summary>
/// <param name="Status">Manual today; automated once a portal/API is available.</param>
/// <param name="Method">The <c>verification_method</c> to record (<c>manual</c>/<c>portal</c>/<c>api</c>).</param>
/// <param name="ExternalResponseJson">Any raw source response, persisted for audit (null for manual).</param>
public readonly record struct CredentialVerificationResult(
CredentialVerificationStatus Status,
string Method,
string? ExternalResponseJson);
@@ -0,0 +1,27 @@
#nullable enable
namespace Baya.Application.Contracts.Common;
/// <summary>
/// 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.
/// </summary>
public interface IIdentityKycProvider
{
Task<IdentityKycResult> VerifyAsync(string nationalId, string? livenessPayload, CancellationToken cancellationToken = default);
}
/// <summary>Outcome of an <see cref="IIdentityKycProvider"/> verification.</summary>
/// <param name="Passed">Whether identity + liveness passed.</param>
/// <param name="MatchedName">The full name the vendor matched (for the credential cross-check), when passed.</param>
/// <param name="VendorRef">The vendor transaction id, kept for audit.</param>
/// <param name="ExternalResponseJson">The raw vendor response blob, persisted for audit.</param>
/// <param name="FailureReason">A reason when <see cref="Passed"/> is false.</param>
public readonly record struct IdentityKycResult(
bool Passed,
string? MatchedName,
string VendorRef,
string ExternalResponseJson,
string? FailureReason);
@@ -0,0 +1,27 @@
#nullable enable
namespace Baya.Application.Contracts.Common;
/// <summary>
/// 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.
/// </summary>
public interface IShahkarVerifier
{
Task<ShahkarMatchResult> MatchAsync(string? phoneNumber, string? nationalId, CancellationToken cancellationToken = default);
}
/// <summary>Outcome of a <see cref="IShahkarVerifier"/> inquiry.</summary>
/// <param name="Matched">Whether the SIM is bound to the given national id.</param>
/// <param name="IsSharedSim">The explicit shared-SIM failure state (raises a support alert).</param>
/// <param name="VendorRef">The vendor transaction id, kept for audit.</param>
/// <param name="ExternalResponseJson">The raw vendor response blob, persisted for audit.</param>
/// <param name="FailureReason">A non-accusatory reason when <see cref="Matched"/> is false.</param>
public readonly record struct ShahkarMatchResult(
bool Matched,
bool IsSharedSim,
string VendorRef,
string ExternalResponseJson,
string? FailureReason);
@@ -26,6 +26,10 @@ public interface INurseBankAccountRepository
/// primary).</summary>
Task<bool> HasAnyAsync(long nurseId, CancellationToken cancellationToken);
/// <summary>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.</summary>
Task<NurseBankAccount?> GetPrimaryAsync(long nurseId, CancellationToken cancellationToken);
/// <summary>No-tracking projection of the nurse's accounts with the IBAN masked (last 4 only).</summary>
Task<IReadOnlyList<NurseBankAccountDto>> ListAsync(long nurseId, CancellationToken cancellationToken);
}
@@ -9,6 +9,10 @@ public interface INurseProfileRepository
/// <summary>Tracked lookup of the nurse's profile by owning user id (for upsert/toggle).</summary>
Task<NurseProfile?> GetByUserIdAsync(int userId, CancellationToken cancellationToken);
/// <summary>Tracked lookup of the nurse's profile by its own id — the verification finalize/suspend
/// transaction loads it to flip the guarded <c>is_verified</c> flag in the same commit.</summary>
Task<NurseProfile?> GetTrackedByIdAsync(long nurseProfileId, CancellationToken cancellationToken);
Task AddAsync(NurseProfile profile, CancellationToken cancellationToken);
/// <summary>No-tracking projection of the signed-in nurse's profile, incl. read-only verified flag
@@ -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();
}
@@ -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;
/// <summary>
/// Persistence for the verification pipeline. Tracked getters (for the state-changing handlers) load the
/// aggregate <see cref="NurseVerification"/> with its steps so the finalize transaction can flip
/// <c>nurse_profiles.is_verified</c> in one <c>SaveChanges</c>. Read getters are projected + paginated;
/// document-bearing reads take a <paramref name="signUrl"/> so signed GET URLs are minted without the
/// repository depending on the storage seam.
/// </summary>
public interface IVerificationRepository
{
// --- Step-type catalog ---
Task<VerificationStepType?> GetStepTypeByIdAsync(long id, CancellationToken cancellationToken);
Task<bool> StepTypeCodeExistsAsync(string code, CancellationToken cancellationToken);
Task<bool> StepTypeInUseAsync(long stepTypeId, CancellationToken cancellationToken);
Task AddStepTypeAsync(VerificationStepType stepType, CancellationToken cancellationToken);
Task<IReadOnlyList<VerificationStepTypeDto>> ListStepTypesAsync(bool includeInactive, CancellationToken cancellationToken);
Task<IReadOnlyList<VerificationStepType>> GetActiveRequiredStepTypesAsync(CancellationToken cancellationToken);
// --- Nurse verification aggregate (tracked, for mutation) ---
Task<NurseVerification?> GetTrackedByNurseIdAsync(long nurseId, CancellationToken cancellationToken);
Task<NurseVerification?> GetTrackedByIdAsync(long nurseVerificationId, CancellationToken cancellationToken);
Task AddVerificationAsync(NurseVerification verification, CancellationToken cancellationToken);
/// <summary>Tracked step scoped to the nurse (tenancy) with its step-type loaded; null if not owned.</summary>
Task<VerificationStep?> GetTrackedStepForNurseAsync(long stepId, long nurseId, CancellationToken cancellationToken);
/// <summary>Tracked step with its parent verification (and all sibling steps) + step-type, for an admin
/// decision that must re-aggregate the whole verification.</summary>
Task<VerificationStep?> GetTrackedStepWithVerificationAsync(long stepId, CancellationToken cancellationToken);
// --- Documents / credentials ---
Task AddDocumentAsync(VerificationDocument document, CancellationToken cancellationToken);
Task AddCredentialAsync(NurseCredential credential, CancellationToken cancellationToken);
// --- Projected reads ---
Task<VerificationStatusDto?> GetStatusForNurseAsync(long nurseId, CancellationToken cancellationToken);
Task<PagedResult<AdminPendingStepDto>> ListPendingStepsAsync(
VerificationStepStatus? status, int page, int pageSize, Func<string, string> signUrl, CancellationToken cancellationToken);
Task<AdminVerificationDetailDto?> GetDetailAsync(long nurseVerificationId, Func<string, string> signUrl, CancellationToken cancellationToken);
Task<TrustBadgeDto?> GetTrustBadgeAsync(long nurseId, DateOnly today, CancellationToken cancellationToken);
/// <summary>The nurse's verified identity name ("Name FamilyName") for the credential holder cross-check.</summary>
Task<string?> GetNurseIdentityNameAsync(long nurseId, CancellationToken cancellationToken);
/// <summary>Tracked <c>users</c> row for the signed-in nurse — the identity-KYC step populates
/// <c>national_id</c> + <c>national_id_verified_at</c> and Shahkar sets <c>shahkar_verified_at</c> on it.</summary>
Task<Baya.Domain.Entities.User.User?> GetTrackedUserAsync(int userId, CancellationToken cancellationToken);
/// <summary>Passed, time-limited steps whose expiry has lapsed — the expiry-scan worklist (paginated).</summary>
Task<IReadOnlyList<ExpiringStepRow>> GetExpiredPassedStepsAsync(
DateTimeOffset asOf, int page, int pageSize, CancellationToken cancellationToken);
}
/// <summary>An expired, previously-passed step that the scanner must revert + re-gate.</summary>
public readonly record struct ExpiringStepRow(long StepId, long NurseVerificationId, long NurseId);
@@ -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<ConfirmDocumentUploadCommand, OperationResult<DocumentConfirmedResult>>
{
public async ValueTask<OperationResult<DocumentConfirmedResult>> Handle(
ConfirmDocumentUploadCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<DocumentConfirmedResult>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<DocumentConfirmedResult>.ForbiddenResult("Only a nurse can upload verification documents.");
var context = await unitOfWork.NurseProfileRepository.GetIdentityContextByUserIdAsync(userId, cancellationToken);
if (context is null)
return OperationResult<DocumentConfirmedResult>.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<DocumentConfirmedResult>.NotFoundResult("Verification step not found.");
if (step.IsAutomated)
return OperationResult<DocumentConfirmedResult>.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<DocumentConfirmedResult>.NotFoundResult("Nurse profile not found.");
VerificationAggregator.Finalize(step.NurseVerification, profile, now);
await unitOfWork.CommitAsync();
return OperationResult<DocumentConfirmedResult>.SuccessResult(new DocumentConfirmedResult(document.Id, step.Status.ToCode()));
}
}
@@ -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<ConfirmDocumentUploadCommand>
{
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);
}
}
@@ -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;
/// <summary>
/// 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 <c>in_review</c>. <c>StepId</c> is route-supplied.
/// </summary>
public record ConfirmDocumentUploadCommand(
long StepId,
string ObjectStorageKey,
string IntegrityHash,
string ContentType,
long FileSizeBytes,
string? OriginalFileName) : IRequest<OperationResult<DocumentConfirmedResult>>;
@@ -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<AdminDeactivateStepTypeCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(AdminDeactivateStepTypeCommand request, CancellationToken cancellationToken)
{
var type = await unitOfWork.VerificationRepository.GetStepTypeByIdAsync(request.Id, cancellationToken);
if (type is null)
return OperationResult<bool>.NotFoundResult("Step type not found.");
type.IsActive = false;
await unitOfWork.CommitAsync();
await VerificationCache.InvalidateStepTypesAsync(cache, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.DeactivateStepType;
/// <summary>Deactivates a step-type (sets <c>is_active = false</c>) — never a hard delete, so historical
/// verifications that seeded a step from it keep their meaning.</summary>
public record AdminDeactivateStepTypeCommand(long Id) : IRequest<OperationResult<bool>>;
@@ -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<RequestDocumentUploadUrlCommand, OperationResult<UploadUrlResult>>
{
public async ValueTask<OperationResult<UploadUrlResult>> Handle(
RequestDocumentUploadUrlCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<UploadUrlResult>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<UploadUrlResult>.ForbiddenResult("Only a nurse can upload verification documents.");
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } id)
return OperationResult<UploadUrlResult>.NotFoundResult("No nurse profile exists yet.");
var step = await unitOfWork.VerificationRepository.GetTrackedStepForNurseAsync(request.StepId, id, cancellationToken);
if (step is null)
return OperationResult<UploadUrlResult>.NotFoundResult("Verification step not found.");
if (step.IsAutomated)
return OperationResult<UploadUrlResult>.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<UploadUrlResult>.SuccessResult(new UploadUrlResult(key, uploadUrl));
}
}
@@ -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<RequestDocumentUploadUrlCommand>
{
public RequestDocumentUploadUrlCommandValidator()
{
RuleFor(x => x.ContentType).NotEmpty().MaximumLength(100);
RuleFor(x => x.FileName).MaximumLength(260);
}
}
@@ -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;
/// <summary>Returns a signed PUT URL for a manual-evidence upload on the nurse's own step. <c>StepId</c> is
/// route-supplied.</summary>
public record RequestDocumentUploadUrlCommand(long StepId, string ContentType, string? FileName)
: IRequest<OperationResult<UploadUrlResult>>;
@@ -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<AdminReviewStepCommand, OperationResult<ReviewStepResult>>
{
public async ValueTask<OperationResult<ReviewStepResult>> Handle(AdminReviewStepCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
return OperationResult<ReviewStepResult>.UnauthorizedResult("Not authenticated.");
var repo = unitOfWork.VerificationRepository;
var step = await repo.GetTrackedStepWithVerificationAsync(request.StepId, cancellationToken);
if (step is null)
return OperationResult<ReviewStepResult>.NotFoundResult("Verification step not found.");
if (step.IsAutomated)
return OperationResult<ReviewStepResult>.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<ReviewStepResult>.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<ReviewStepResult>.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<string, object?>
{
["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<ReviewStepResult>.SuccessResult(new ReviewStepResult(step.Id, step.Status.ToCode(), recordedCredential?.Id));
}
private async ValueTask<OperationResult<NurseCredential>> 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<NurseCredential>.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<NurseCredential>.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<NurseCredential>.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<NurseCredential>.SuccessResult(credential);
}
private static string FirstError(IOperationResult result)
=> result.ErrorMessages.Count > 0 ? result.ErrorMessages[0].Value : "Credential could not be recorded.";
}
@@ -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<AdminReviewStepCommand>
{
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);
}
}
@@ -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;
/// <summary>
/// Admin manual decision on a step. <c>Approve=false</c> requires a <c>RejectionReason</c>. Approving a
/// credential-bearing step (MoH / INO / criminal-record) records a <c>nurse_credentials</c> row — the
/// encrypted number plus a holder name that must cross-check against the verified identity. <c>StepId</c>
/// is route-supplied.
/// </summary>
public record AdminReviewStepCommand(
long StepId,
bool Approve,
string? RejectionReason,
string? CredentialNumber,
string? HolderName,
string? IssuingAuthority,
DateOnly? IssuedAt,
DateOnly? ExpiresAt,
string? VerificationSource) : IRequest<OperationResult<ReviewStepResult>>;
@@ -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<RunBankAccountVerificationCommand, OperationResult<RunStepResult>>
{
public async ValueTask<OperationResult<RunStepResult>> Handle(RunBankAccountVerificationCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<RunStepResult>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<RunStepResult>.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<RunStepResult>.NotFoundResult("No nurse profile exists yet.");
if (string.IsNullOrEmpty(context.NationalId))
return OperationResult<RunStepResult>.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<RunStepResult>.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<RunStepResult>.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<RunStepResult>.NotFoundResult("Nurse profile not found.");
VerificationAggregator.Finalize(verification, profile, now);
await unitOfWork.CommitAsync();
return OperationResult<RunStepResult>.SuccessResult(new RunStepResult(step.Id, step.Status.ToCode(), step.FailureReason));
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.RunBankAccountVerification;
/// <summary>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.</summary>
public record RunBankAccountVerificationCommand : IRequest<OperationResult<RunStepResult>>;
@@ -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<RunIdentityKycCommand, OperationResult<RunStepResult>>
{
public async ValueTask<OperationResult<RunStepResult>> Handle(RunIdentityKycCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<RunStepResult>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<RunStepResult>.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<RunStepResult>.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<RunStepResult>.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<RunStepResult>.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<RunStepResult>.NotFoundResult("Nurse profile not found.");
VerificationAggregator.Finalize(verification, profile, now);
await unitOfWork.CommitAsync();
return OperationResult<RunStepResult>.SuccessResult(new RunStepResult(step.Id, step.Status.ToCode(), step.FailureReason));
}
}
@@ -0,0 +1,14 @@
using FluentValidation;
namespace Baya.Application.Features.Verification.Commands.RunIdentityKyc;
public sealed class RunIdentityKycCommandValidator : AbstractValidator<RunIdentityKycCommand>
{
public RunIdentityKycCommandValidator()
{
RuleFor(x => x.NationalId)
.NotEmpty()
.Matches(@"^\d{10}$")
.WithMessage("National ID must be exactly 10 digits.");
}
}
@@ -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;
/// <summary>Runs the automated identity-KYC step for the signed-in nurse. On pass it populates the verified
/// <c>users.national_id</c> — the anchor every downstream comparison (Shahkar, IBAN, credential cross-check)
/// uses.</summary>
public record RunIdentityKycCommand(string NationalId, string? LivenessPayload)
: IRequest<OperationResult<RunStepResult>>;
@@ -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<RunShahkarMatchCommand, OperationResult<RunStepResult>>
{
public async ValueTask<OperationResult<RunStepResult>> Handle(RunShahkarMatchCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<RunStepResult>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<RunStepResult>.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<RunStepResult>.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<RunStepResult>.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<RunStepResult>.NotFoundResult("User not found.");
if (string.IsNullOrEmpty(user.NationalId))
return OperationResult<RunStepResult>.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<RunStepResult>.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<RunStepResult>.SuccessResult(new RunStepResult(step.Id, step.Status.ToCode(), step.FailureReason));
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.RunShahkarMatch;
/// <summary>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.</summary>
public record RunShahkarMatchCommand : IRequest<OperationResult<RunStepResult>>;
@@ -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<ScanExpiringCredentialsCommand, OperationResult<ScanExpiringResult>>
{
public async ValueTask<OperationResult<ScanExpiringResult>> 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<ScanExpiringResult>.SuccessResult(new ScanExpiringResult(scannedSteps, revertedNurses));
}
}
@@ -0,0 +1,11 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
/// <summary>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.</summary>
public record ScanExpiringCredentialsCommand(int Page = 1, int PageSize = 50)
: IRequest<OperationResult<ScanExpiringResult>>;
@@ -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<SubmitNurseVerificationCommand, OperationResult<VerificationStatusDto>>
{
public async ValueTask<OperationResult<VerificationStatusDto>> Handle(
SubmitNurseVerificationCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<VerificationStatusDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<VerificationStatusDto>.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<VerificationStatusDto>.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<VerificationStatusDto>.NotFoundResult("Nurse profile not found.");
VerificationAggregator.Finalize(verification, profile, now);
await unitOfWork.CommitAsync();
var status = await repo.GetStatusForNurseAsync(nurseId, cancellationToken);
return OperationResult<VerificationStatusDto>.SuccessResult(status!);
}
}
@@ -0,0 +1,12 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.SubmitVerification;
/// <summary>
/// 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.
/// </summary>
public record SubmitNurseVerificationCommand : IRequest<OperationResult<VerificationStatusDto>>;
@@ -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<AdminSuspendVerificationCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(AdminSuspendVerificationCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
var verification = await unitOfWork.VerificationRepository.GetTrackedByIdAsync(request.NurseVerificationId, cancellationToken);
if (verification is null)
return OperationResult<bool>.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<bool>.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<string, object?> { ["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<bool>.SuccessResult(true);
}
}
@@ -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<AdminSuspendVerificationCommand>
{
public AdminSuspendVerificationCommandValidator()
{
RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.SuspendVerification;
/// <summary>Suspends a nurse's verification and reverses the <c>is_verified</c> flip in the same
/// transaction (un-publishing the nurse from search). <c>NurseVerificationId</c> is route-supplied.</summary>
public record AdminSuspendVerificationCommand(long NurseVerificationId, string Reason)
: IRequest<OperationResult<bool>>;
@@ -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<AdminUpsertStepTypeCommand, OperationResult<VerificationStepTypeDto>>
{
public async ValueTask<OperationResult<VerificationStepTypeDto>> 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<VerificationStepTypeDto>.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<VerificationStepTypeDto>.FailureResult(nameof(request.Code), "Code cannot change once the step type is in use.");
if (await repo.StepTypeCodeExistsAsync(request.Code, cancellationToken))
return OperationResult<VerificationStepTypeDto>.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<VerificationStepTypeDto>.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<VerificationStepTypeDto>.SuccessResult(new VerificationStepTypeDto(
type.Id, type.Code, type.DisplayName, type.Description, type.IsRequired, type.IsAutomated,
type.AutomationProvider, type.SortOrder, type.IsActive));
}
}
@@ -0,0 +1,20 @@
using FluentValidation;
namespace Baya.Application.Features.Verification.Commands.UpsertStepType;
public sealed class AdminUpsertStepTypeCommandValidator : AbstractValidator<AdminUpsertStepTypeCommand>
{
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 (az, 09, 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);
}
}
@@ -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;
/// <summary>
/// Admin create/update of a pipeline step-type. Adding a step is one row (the pipeline is data-driven).
/// <paramref name="Id"/> null creates; otherwise updates. <c>Code</c> is immutable once the step-type is in
/// use by a nurse verification.
/// </summary>
public record AdminUpsertStepTypeCommand(
long? Id,
string Code,
string DisplayName,
string? Description,
bool IsRequired,
bool IsAutomated,
string? AutomationProvider,
int SortOrder,
bool IsActive) : IRequest<OperationResult<VerificationStepTypeDto>>;
@@ -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<GetNurseVerificationStatusQuery, OperationResult<VerificationStatusDto>>
{
public async ValueTask<OperationResult<VerificationStatusDto>> Handle(
GetNurseVerificationStatusQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<VerificationStatusDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<VerificationStatusDto>.ForbiddenResult("Only a nurse can read their verification.");
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } id)
return OperationResult<VerificationStatusDto>.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<VerificationStatusDto>.SuccessResult(status);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Queries.GetStatus;
/// <summary>The signed-in nurse's verification checklist + aggregate status + "what's blocking bookability".</summary>
public record GetNurseVerificationStatusQuery : IRequest<OperationResult<VerificationStatusDto>>;
@@ -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<GetVerifiedTrustBadgeQuery, OperationResult<TrustBadgeDto>>
{
public async ValueTask<OperationResult<TrustBadgeDto>> Handle(GetVerifiedTrustBadgeQuery request, CancellationToken cancellationToken)
{
var key = VerificationCache.BadgeKey(request.NurseId);
var cached = await cache.GetAsync<TrustBadgeDto>(key, cancellationToken);
if (cached is not null)
return OperationResult<TrustBadgeDto>.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<TrustBadgeDto>.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<TrustBadgeDto>.SuccessResult(badge);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Queries.GetTrustBadge;
/// <summary>The public "verified" trust badge for a nurse — verified status + credential <b>types</b> held
/// (never the numbers). Cached.</summary>
public record GetVerifiedTrustBadgeQuery(long NurseId) : IRequest<OperationResult<TrustBadgeDto>>;
@@ -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<AdminGetVerificationDetailQuery, OperationResult<AdminVerificationDetailDto>>
{
public async ValueTask<OperationResult<AdminVerificationDetailDto>> Handle(
AdminGetVerificationDetailQuery request, CancellationToken cancellationToken)
{
var detail = await unitOfWork.VerificationRepository.GetDetailAsync(
request.NurseVerificationId, key => objectStorage.GetUrl(key), cancellationToken);
return detail is null
? OperationResult<AdminVerificationDetailDto>.NotFoundResult("Verification not found.")
: OperationResult<AdminVerificationDetailDto>.SuccessResult(detail);
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Queries.GetVerificationDetail;
/// <summary>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.</summary>
public record AdminGetVerificationDetailQuery(long NurseVerificationId)
: IRequest<OperationResult<AdminVerificationDetailDto>>;
@@ -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<AdminListPendingStepsQuery, OperationResult<PagedResult<AdminPendingStepDto>>>
{
public async ValueTask<OperationResult<PagedResult<AdminPendingStepDto>>> 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<PagedResult<AdminPendingStepDto>>.SuccessResult(result);
}
}
@@ -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;
/// <summary>The admin manual-review worklist — steps in the given status (default <c>in_review</c>) with
/// their submitted documents (signed GET URLs). Projected + paginated.</summary>
public record AdminListPendingStepsQuery(string? Status = null, int Page = 1, int PageSize = 50)
: IRequest<OperationResult<PagedResult<AdminPendingStepDto>>>;
@@ -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<AdminListStepTypesQuery, OperationResult<IReadOnlyList<VerificationStepTypeDto>>>
{
public async ValueTask<OperationResult<IReadOnlyList<VerificationStepTypeDto>>> 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<IReadOnlyList<VerificationStepTypeDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Queries.ListStepTypes;
/// <summary>Admin catalog of pipeline step-types (read-heavy reference data, cached). Set
/// <paramref name="IncludeInactive"/> to include deactivated rows.</summary>
public record AdminListStepTypesQuery(bool IncludeInactive = false)
: IRequest<OperationResult<IReadOnlyList<VerificationStepTypeDto>>>;
@@ -0,0 +1,37 @@
#nullable enable
using Baya.Application.Contracts.Common;
namespace Baya.Application.Features.Verification;
/// <summary>
/// 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).
/// </summary>
internal static class VerificationCache
{
private const string VersionKey = "verification:step_types:version";
public static readonly TimeSpan Ttl = TimeSpan.FromHours(1);
/// <summary>Short TTL: a suspended/expired nurse is also evicted explicitly, so the badge is never
/// stale-verified for long.</summary>
public static readonly TimeSpan BadgeTtl = TimeSpan.FromSeconds(60);
public static ValueTask<string> 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");
}
@@ -0,0 +1,36 @@
#nullable enable
namespace Baya.Application.Models.Verification;
/// <summary>A row in the admin manual-review worklist — one pending step with its submitted documents
/// (signed GET URLs).</summary>
public record AdminPendingStepDto(
long NurseVerificationId,
long NurseId,
string NurseName,
long StepId,
string StepCode,
string StepDisplayName,
string Status,
DateTimeOffset? SubmittedAt,
IReadOnlyList<VerificationDocumentDto> Documents);
/// <summary>A step inside the admin per-nurse detail view.</summary>
public record AdminStepDetailDto(
long StepId,
string Code,
string DisplayName,
string Status,
bool IsAutomated,
DateTimeOffset? ExpiresAt,
string? FailureReason,
IReadOnlyList<VerificationDocumentDto> Documents);
/// <summary>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.</summary>
public record AdminVerificationDetailDto(
long NurseVerificationId,
long NurseId,
string IdentityName,
string Status,
IReadOnlyList<AdminStepDetailDto> Steps,
IReadOnlyList<NurseCredentialDto> Credentials);
@@ -0,0 +1,60 @@
#nullable enable
namespace Baya.Application.Models.Verification;
/// <summary>An admin step-type catalog row.</summary>
public record VerificationStepTypeDto(
long Id,
string Code,
string DisplayName,
string? Description,
bool IsRequired,
bool IsAutomated,
string? AutomationProvider,
int SortOrder,
bool IsActive);
/// <summary>One step in the nurse's checklist. <c>Status</c> is a snake_case code.</summary>
public record VerificationStepDto(
long Id,
string Code,
string DisplayName,
string Status,
bool IsAutomated,
DateTimeOffset? ExpiresAt,
string? FailureReason);
/// <summary>
/// The nurse's aggregate verification state + per-step checklist. <c>IsBookable</c> mirrors
/// <c>nurse_profiles.is_verified</c>; <c>BlockingSteps</c> lists the codes of required steps not yet passed
/// (what the nurse must still complete to become bookable).
/// </summary>
public record VerificationStatusDto(
string Status,
bool IsBookable,
IReadOnlyList<string> BlockingSteps,
IReadOnlyList<VerificationStepDto> Steps);
/// <summary>Uploaded-evidence metadata + a short-lived signed URL. Bytes never touch the DB.</summary>
public record VerificationDocumentDto(
long Id,
string ContentType,
long FileSizeBytes,
string? OriginalFileName,
string Url);
/// <summary>A structured credential — the number is <b>never</b> serialized.</summary>
public record NurseCredentialDto(
long Id,
string CredentialType,
string HolderNameSnapshot,
string IssuingAuthority,
DateOnly? IssuedAt,
DateOnly? ExpiresAt,
string VerificationMethod);
/// <summary>The public "verified" badge — credential <b>types</b> held, never the numbers.</summary>
public record TrustBadgeDto(
long NurseId,
bool IsVerified,
DateTimeOffset? ApprovedAt,
IReadOnlyList<string> CredentialTypes);
@@ -0,0 +1,17 @@
#nullable enable
namespace Baya.Application.Models.Verification;
/// <summary>The signed PUT URL for a manual-evidence upload + the storage key the client echoes on confirm.</summary>
public record UploadUrlResult(string ObjectStorageKey, string UploadUrl);
/// <summary>The persisted document-metadata row + the step's resulting status.</summary>
public record DocumentConfirmedResult(long DocumentId, string StepStatus);
/// <summary>The outcome of an automated step run (identity KYC / Shahkar / bank ownership).</summary>
public record RunStepResult(long StepId, string StepStatus, string? FailureReason);
/// <summary>The outcome of an admin manual decision on a step.</summary>
public record ReviewStepResult(long StepId, string StepStatus, long? CredentialId);
/// <summary>The result of an admin-triggered expiry scan.</summary>
public record ScanExpiringResult(int ScannedSteps, int RevertedNurses);
@@ -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<string> All =
[
LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, PaymentAnomaly, FraudSignal
LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, SharedSim, PaymentAnomaly, FraudSignal
];
}
@@ -0,0 +1,42 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// 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. <see cref="CredentialNumber"/> is
/// encrypted PII (through <c>IFieldEncryptor</c>); <see cref="HolderNameSnapshot"/> is cross-checked against
/// the nurse's verified identity name before the credential is recorded — never trust an uploaded file alone.
/// </summary>
public class NurseCredential : BaseEntity<long>
{
public long NurseId { get; set; }
/// <summary>One of <see cref="CredentialTypes"/>.</summary>
public string CredentialType { get; set; } = string.Empty;
/// <summary>Encrypted at rest — never serialized on the wire.</summary>
public string CredentialNumber { get; set; } = string.Empty;
/// <summary>Name as printed on the credential, snapshotted for the identity cross-check.</summary>
public string HolderNameSnapshot { get; set; } = string.Empty;
public string IssuingAuthority { get; set; } = string.Empty;
public DateOnly? IssuedAt { get; set; }
/// <summary>Drives renewal alerts and the expiry scanner re-gate.</summary>
public DateOnly? ExpiresAt { get; set; }
/// <summary>Portal URL / method used to verify (audit defensibility).</summary>
public string? VerificationSource { get; set; }
/// <summary>One of <see cref="VerificationMethods"/> (<c>manual</c> today for MoH/INO/criminal).</summary>
public string VerificationMethod { get; set; } = VerificationMethods.Manual;
public int? VerifiedByAdminId { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}
@@ -0,0 +1,39 @@
#nullable enable
using Baya.Domain.Common;
using Baya.Domain.Entities.Identity;
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// The master per-nurse verification record and the <b>single source of verification truth</b>. Its
/// <see cref="Status"/> rolls up the per-step outcomes; the derived <c>nurse_profiles.is_verified</c>
/// boolean is flipped only inside the finalize transaction when every required step has passed (and
/// reversed on suspension). The legacy <c>nurse_profiles.verification_status</c> column was deliberately
/// cut — never reintroduce a second copy of this state.
/// </summary>
public class NurseVerification : BaseEntity<long>
{
public long NurseId { get; set; }
/// <summary>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 <c>is_verified</c>.</summary>
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; }
/// <summary>The admin who last drove a manual decision (review/suspend).</summary>
public int? ReviewedByAdminId { get; set; }
public string? InternalNotes { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public ICollection<VerificationStep> Steps { get; set; } = new List<VerificationStep>();
}
@@ -0,0 +1,66 @@
#nullable enable
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// 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.
/// </summary>
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.")
};
/// <summary>Lenient parse for a query-string filter; returns null for a missing/unknown value.</summary>
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
};
}
@@ -0,0 +1,29 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// Metadata for an uploaded evidence file — <b>bytes never touch the DB</b>. The file lives in object
/// storage behind a short-lived signed URL keyed by <see cref="ObjectStorageKey"/>; the
/// <see cref="IntegrityHash"/> detects tampering/swap after upload. Never public.
/// </summary>
public class VerificationDocument : BaseEntity<long>
{
public long StepId { get; set; }
public VerificationStep Step { get; set; } = null!;
/// <summary>The opaque <c>IObjectStorage</c> key the bytes live under.</summary>
public string ObjectStorageKey { get; set; } = string.Empty;
/// <summary>Content hash (hex) to detect a later tamper/swap of the stored object.</summary>
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; }
}
@@ -0,0 +1,16 @@
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// The aggregate state of a nurse's verification (the single source of verification truth). Persisted as
/// its snake_case code (see <see cref="VerificationCodes"/>). The derived <c>nurse_profiles.is_verified</c>
/// boolean is written solely by the finalize transaction when this reaches <see cref="Approved"/>.
/// </summary>
public enum VerificationStatus
{
NotStarted,
Pending,
InReview,
Approved,
Rejected,
Suspended
}
@@ -0,0 +1,37 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// One step per nurse per pipeline step-type. Carries the raw KYC-vendor payload
/// (<see cref="ExternalResponseJson"/>) for audit, an optional <see cref="ExpiresAt"/> for time-limited
/// steps, and a <b>snapshot</b> of the step-type's automation flag (<see cref="IsAutomated"/>) — read the
/// snapshot, never the live step-type, so historical records survive later catalog edits.
/// </summary>
public class VerificationStep : BaseEntity<long>
{
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;
/// <summary>Raw KYC-vendor response, kept so the audit trail survives the mock→real swap.</summary>
public string? ExternalResponseJson { get; set; }
/// <summary>When a time-limited step lapses (criminal-record especially); the scanner reverts on it.</summary>
public DateTimeOffset? ExpiresAt { get; set; }
/// <summary>Snapshotted from the step-type at seed time — never re-read from the live step-type.</summary>
public bool IsAutomated { get; set; }
public DateTimeOffset? StartedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public string? FailureReason { get; set; }
public ICollection<VerificationDocument> Documents { get; set; } = new List<VerificationDocument>();
}
@@ -0,0 +1,16 @@
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// The state of a single verification step. <see cref="Pending"/> = awaiting submission/automation;
/// <see cref="InReview"/> = awaiting a manual admin decision; <see cref="Expired"/> = a time-limited
/// credential lapsed (the scanner reverts it and re-gates bookability). Persisted as its snake_case code.
/// </summary>
public enum VerificationStepStatus
{
NotStarted,
Pending,
InReview,
Passed,
Failed,
Expired
}
@@ -0,0 +1,29 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// The admin catalog of pipeline steps — data, not a code enum. Adding a regulatory requirement is one
/// row (<see cref="Code"/> is the stable machine key). <see cref="IsAutomated"/> is snapshotted onto each
/// <see cref="VerificationStep"/> at seed time, so toggling it here never rewrites the meaning of past
/// verifications. Deactivate (<see cref="IsActive"/>) rather than delete — no soft-delete here.
/// </summary>
public class VerificationStepType : BaseEntity<long>
{
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; }
/// <summary>Informational vendor tag (e.g. <c>shahkar</c>, <c>identity_kyc_vendor</c>). The real seam
/// is config-selected; nothing branches on this string.</summary>
public string? AutomationProvider { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
public ICollection<VerificationStep> Steps { get; set; } = new List<VerificationStep>();
}
@@ -0,0 +1,48 @@
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// The six stable machine codes of the seeded pipeline steps. The pipeline is data-driven — these live as
/// rows in <c>verification_step_types</c>, 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.
/// </summary>
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";
/// <summary>Steps that, on admin pass, record a row in <c>nurse_credentials</c>.</summary>
public static readonly IReadOnlyList<string> CredentialBearing =
[MohCompetencyLicense, InoMembership, CriminalRecord];
/// <summary>The time-limited step whose certificate expires (drives the expiry scanner re-gate).</summary>
public const string TimeLimited = CriminalRecord;
}
/// <summary>Stable codes for <c>nurse_credentials.credential_type</c> (aligned with the step codes).</summary>
public static class CredentialTypes
{
public const string MohCompetencyLicense = VerificationStepTypeCodes.MohCompetencyLicense;
public const string InoMembership = VerificationStepTypeCodes.InoMembership;
public const string CriminalRecord = VerificationStepTypeCodes.CriminalRecord;
}
/// <summary>Stable codes for <c>nurse_credentials.verification_method</c>.</summary>
public static class VerificationMethods
{
public const string Manual = "manual";
public const string Portal = "portal";
public const string Api = "api";
}
/// <summary>Stable codes for <c>verification_step_types.automation_provider</c> (informational; the mock
/// swap is config-selected, never branched on here).</summary>
public static class AutomationProviders
{
public const string IdentityKycVendor = "identity_kyc_vendor";
public const string Shahkar = "shahkar";
public const string Sheba = "sheba";
}
@@ -0,0 +1,23 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Domain.Entities.Verification;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Default <see cref="ICredentialVerifier"/> — and the mock. MoH پروانه صلاحیت حرفه‌ای and INO membership
/// have <b>no public B2B API</b>, so verification is a manual admin review of the uploaded document against
/// the official portal: every call returns <see cref="CredentialVerificationStatus.RequiresManualReview"/>
/// with <c>verification_method = manual</c>. When an <c>api</c>/<c>portal</c> source becomes available, a
/// real implementation replaces this registration and starts returning
/// <see cref="CredentialVerificationStatus.Verified"/>/<see cref="CredentialVerificationStatus.Failed"/>
/// with the matching method — callers are unchanged.
/// </summary>
public sealed class MockCredentialVerifier : ICredentialVerifier
{
public Task<CredentialVerificationResult> VerifyAsync(string credentialType, string? credentialNumber, CancellationToken cancellationToken = default)
=> Task.FromResult(new CredentialVerificationResult(
CredentialVerificationStatus.RequiresManualReview,
VerificationMethods.Manual,
ExternalResponseJson: null));
}
@@ -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;
/// <summary>
/// Mock <see cref="IIdentityKycProvider"/>: a deterministic fake identity + liveness check — no real
/// OCR/liveness call. Passes every well-formed national id except the configured
/// <see cref="IdentityKycOptions.FailNationalId"/>. 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.
/// </summary>
public sealed class MockIdentityKycProvider(IOptions<SeamOptions> options) : IIdentityKycProvider
{
private readonly IdentityKycOptions _options = options.Value.IdentityKyc;
public Task<IdentityKycResult> 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];
}
@@ -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;
/// <summary>
/// Mock <see cref="IShahkarVerifier"/>: a deterministic fake شاهکار phone↔national-id inquiry — no real
/// Shahkar/KYC call. Matches every pair except the configured <see cref="ShahkarOptions.SharedSimPhone"/>
/// (returns the explicit shared-SIM failure state) and <see cref="ShahkarOptions.MismatchNationalId"/>
/// (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.
/// </summary>
public sealed class MockShahkarVerifier(IOptions<SeamOptions> options) : IShahkarVerifier
{
private readonly ShahkarOptions _options = options.Value.Shahkar;
public Task<ShahkarMatchResult> 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];
}
@@ -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();
}
/// <summary>
/// Tunes the mock <c>IShahkarVerifier</c> (phone↔national-id binding). A submitted phone equal to
/// <see cref="SharedSimPhone"/> returns the explicit shared-SIM failure; a national id equal to
/// <see cref="MismatchNationalId"/> returns a plain mismatch; every other pair matches. The real vendor
/// implementation ignores these.
/// </summary>
public sealed class ShahkarOptions
{
/// <summary>The designated test phone that returns the shared-SIM failure state.</summary>
public string SharedSimPhone { get; set; } = "09120000000";
/// <summary>The designated test national id that returns a plain phone↔national-id mismatch.</summary>
public string MismatchNationalId { get; set; } = "1111111111";
}
/// <summary>
/// Tunes the mock <c>IIdentityKycProvider</c>. A national id equal to <see cref="FailNationalId"/> fails
/// KYC; every other (well-formed) national id passes with <see cref="MatchedName"/>. The real e-KYC vendor
/// implementation ignores these.
/// </summary>
public sealed class IdentityKycOptions
{
/// <summary>The designated test national id that fails identity KYC.</summary>
public string FailNationalId { get; set; } = "0000000000";
/// <summary>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).</summary>
public string MatchedName { get; set; } = "Verified Nurse";
}
/// <summary>
@@ -36,6 +36,13 @@ public static class ServiceCollectionExtension
// centroid with no network call; a real Neshan/Google geocoding client replaces this registration.
services.AddSingleton<IGeocoder, MockGeocoder>();
// 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<IShahkarVerifier, MockShahkarVerifier>();
services.AddSingleton<IIdentityKycProvider, MockIdentityKycProvider>();
services.AddSingleton<ICredentialVerifier, MockCredentialVerifier>();
return services;
}
}
@@ -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<User, Role, int, UserClaim,
builder.Property(a => 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<NurseCredential>(builder =>
{
builder.Property(c => c.CredentialNumber).HasConversion(encrypted);
});
}
}
@@ -43,6 +43,7 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration<PlatformCo
(13, "auth_otp_resend_seconds", "120", ConfigDataType.Int, "Seconds a phone must wait before another OTP can be requested."),
(14, "auth_otp_max_attempts", "5", ConfigDataType.Int, "Wrong-code attempts allowed before OTP verification is refused until a fresh code."),
(15, "auth_session_ttl_days", "30", ConfigDataType.Int, "Refresh-token session lifetime in days."),
(16, "verification_expiry_scan_cadence_hours", "24", ConfigDataType.Int, "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today)."),
];
return rows
@@ -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 NurseCredentialConfig : IEntityTypeConfiguration<NurseCredential>
{
public void Configure(EntityTypeBuilder<NurseCredential> 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<NurseProfile>()
.WithMany()
.HasForeignKey(c => c.NurseId)
.IsRequired();
builder.HasOne<User>()
.WithMany()
.HasForeignKey(c => c.VerifiedByAdminId)
.IsRequired(false);
builder.HasQueryFilter(c => c.DeletedAt == null);
}
}
@@ -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<NurseVerification>
{
public void Configure(EntityTypeBuilder<NurseVerification> 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<User>()
.WithMany()
.HasForeignKey(v => v.ReviewedByAdminId)
.IsRequired(false);
builder.HasQueryFilter(v => v.DeletedAt == null);
}
}
@@ -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<VerificationDocument>
{
public void Configure(EntityTypeBuilder<VerificationDocument> 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<User>()
.WithMany()
.HasForeignKey(d => d.UploadedByUserId)
.IsRequired();
builder.HasQueryFilter(d => d.DeletedAt == null);
}
}
@@ -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<VerificationStep>
{
public void Configure(EntityTypeBuilder<VerificationStep> 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();
}
}
@@ -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<VerificationStepType>
{
public void Configure(EntityTypeBuilder<VerificationStepType> 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());
}
}
@@ -0,0 +1,54 @@
namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig;
using Baya.Domain.Entities.Verification;
/// <summary>
/// The six MVP pipeline step-types, seeded via <c>HasData</c> 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.
/// </summary>
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();
}
}
@@ -0,0 +1,302 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class VerificationPipeline : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "verif");
migrationBuilder.CreateTable(
name: "NurseCredentials",
schema: "verif",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
NurseId = table.Column<long>(type: "bigint", nullable: false),
CredentialType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
CredentialNumber = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
HolderNameSnapshot = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
IssuingAuthority = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
IssuedAt = table.Column<DateOnly>(type: "date", nullable: true),
ExpiresAt = table.Column<DateOnly>(type: "date", nullable: true),
VerificationSource = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: true),
VerificationMethod = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
VerifiedByAdminId = table.Column<int>(type: "int", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_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<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
NurseId = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
SubmittedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
ApprovedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
RejectedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
SuspendedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
RejectionReason = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
ReviewedByAdminId = table.Column<int>(type: "int", nullable: true),
InternalNotes = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_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<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Code = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
DisplayName = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
IsRequired = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
IsAutomated = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
AutomationProvider = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_VerificationStepTypes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "VerificationSteps",
schema: "verif",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
NurseVerificationId = table.Column<long>(type: "bigint", nullable: false),
StepTypeId = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
ExternalResponseJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
ExpiresAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
IsAutomated = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
StartedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CompletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
FailureReason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_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<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
StepId = table.Column<long>(type: "bigint", nullable: false),
ObjectStorageKey = table.Column<string>(type: "nvarchar(400)", maxLength: 400, nullable: false),
IntegrityHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ContentType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
FileSizeBytes = table.Column<long>(type: "bigint", nullable: false),
OriginalFileName = table.Column<string>(type: "nvarchar(260)", maxLength: 260, nullable: true),
UploadedByUserId = table.Column<int>(type: "int", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_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" });
}
/// <inheritdoc />
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);
}
}
}
@@ -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
{
/// <inheritdoc />
public partial class SeedVerificationStepTypes : Migration
{
/// <inheritdoc />
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 });
}
/// <inheritdoc />
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);
}
}
}
@@ -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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("CredentialNumber")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("CredentialType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateOnly?>("ExpiresAt")
.HasColumnType("date");
b.Property<string>("HolderNameSnapshot")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateOnly?>("IssuedAt")
.HasColumnType("date");
b.Property<string>("IssuingAuthority")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<string>("VerificationMethod")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("VerificationSource")
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<int?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset?>("ApprovedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("InternalNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("RejectedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("RejectionReason")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int?>("ReviewedByAdminId")
.HasColumnType("int");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<long>("FileSizeBytes")
.HasColumnType("bigint");
b.Property<string>("IntegrityHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("ObjectStorageKey")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("nvarchar(400)");
b.Property<string>("OriginalFileName")
.HasMaxLength(260)
.HasColumnType("nvarchar(260)");
b.Property<long>("StepId")
.HasColumnType("bigint");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("ExpiresAt")
.HasColumnType("datetimeoffset");
b.Property<string>("ExternalResponseJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("FailureReason")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<bool>("IsAutomated")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseVerificationId")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AutomationProvider")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<bool>("IsAutomated")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("IsRequired")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int>("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
}
}
@@ -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()
@@ -42,6 +42,9 @@ internal sealed class NurseBankAccountRepository : BaseAsyncRepository<NurseBank
public Task<bool> HasAnyAsync(long nurseId, CancellationToken cancellationToken)
=> TableNoTracking.AnyAsync(a => a.NurseId == nurseId, cancellationToken);
public Task<NurseBankAccount> GetPrimaryAsync(long nurseId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(a => a.NurseId == nurseId && a.IsPrimary, cancellationToken);
public async Task<IReadOnlyList<NurseBankAccountDto>> ListAsync(long nurseId, CancellationToken cancellationToken)
{
// Decrypt the IBAN in memory, then mask to last-4 — the full value never leaves the repository.
@@ -15,6 +15,9 @@ internal sealed class NurseProfileRepository : BaseAsyncRepository<NurseProfile>
public Task<NurseProfile> GetByUserIdAsync(int userId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
public Task<NurseProfile> GetTrackedByIdAsync(long nurseProfileId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(p => p.Id == nurseProfileId, cancellationToken);
public Task AddAsync(NurseProfile profile, CancellationToken cancellationToken)
=> base.AddAsync(profile);
@@ -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<NurseVerification>, IVerificationRepository
{
public VerificationRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
private IQueryable<VerificationStepType> StepTypes => DbContext.Set<VerificationStepType>();
private IQueryable<VerificationStep> Steps => DbContext.Set<VerificationStep>();
private IQueryable<VerificationDocument> Documents => DbContext.Set<VerificationDocument>();
private IQueryable<NurseCredential> Credentials => DbContext.Set<NurseCredential>();
private IQueryable<NurseProfile> Profiles => DbContext.Set<NurseProfile>();
// --- Step-type catalog ---
public Task<VerificationStepType?> GetStepTypeByIdAsync(long id, CancellationToken cancellationToken)
=> StepTypes.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
public Task<bool> StepTypeCodeExistsAsync(string code, CancellationToken cancellationToken)
=> StepTypes.AsNoTracking().AnyAsync(t => t.Code == code, cancellationToken);
public Task<bool> StepTypeInUseAsync(long stepTypeId, CancellationToken cancellationToken)
=> Steps.AsNoTracking().AnyAsync(s => s.StepTypeId == stepTypeId, cancellationToken);
public async Task AddStepTypeAsync(VerificationStepType stepType, CancellationToken cancellationToken)
=> await DbContext.Set<VerificationStepType>().AddAsync(stepType, cancellationToken);
public Task<IReadOnlyList<VerificationStepTypeDto>> ListStepTypesAsync(bool includeInactive, CancellationToken cancellationToken)
=> ListStepTypesInternalAsync(includeInactive, cancellationToken);
private async Task<IReadOnlyList<VerificationStepTypeDto>> 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<IReadOnlyList<VerificationStepType>> 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<NurseVerification?> GetTrackedByNurseIdAsync(long nurseId, CancellationToken cancellationToken)
=> Table
.Include(v => v.Steps).ThenInclude(s => s.StepType)
.FirstOrDefaultAsync(v => v.NurseId == nurseId, cancellationToken);
public Task<NurseVerification?> 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<VerificationStep?> 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<VerificationStep?> 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<VerificationDocument>().AddAsync(document, cancellationToken);
public async Task AddCredentialAsync(NurseCredential credential, CancellationToken cancellationToken)
=> await DbContext.Set<NurseCredential>().AddAsync(credential, cancellationToken);
// --- Projected reads ---
public async Task<VerificationStatusDto?> 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<PagedResult<AdminPendingStepDto>> ListPendingStepsAsync(
VerificationStepStatus? status, int page, int pageSize, Func<string, string> 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<AdminPendingStepDto>(items, total, page, pageSize);
}
public async Task<AdminVerificationDetailDto?> GetDetailAsync(long nurseVerificationId, Func<string, string> 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<TrustBadgeDto?> 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<string?> GetNurseIdentityNameAsync(long nurseId, CancellationToken cancellationToken)
=> Profiles.AsNoTracking()
.Where(p => p.Id == nurseId)
.Select(p => (p.User.Name ?? "") + " " + (p.User.FamilyName ?? ""))
.FirstOrDefaultAsync(cancellationToken);
public Task<User?> GetTrackedUserAsync(int userId, CancellationToken cancellationToken)
=> DbContext.Set<User>().FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
public async Task<IReadOnlyList<ExpiringStepRow>> 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<Dictionary<long, IReadOnlyList<VerificationDocumentDto>>> LoadDocumentsAsync(
IReadOnlyList<long> stepIds, Func<string, string> signUrl, CancellationToken cancellationToken)
{
if (stepIds.Count == 0)
return new Dictionary<long, IReadOnlyList<VerificationDocumentDto>>();
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<VerificationDocumentDto>)g
.Select(d => new VerificationDocumentDto(d.Id, d.ContentType, d.FileSizeBytes, d.OriginalFileName, signUrl(d.ObjectStorageKey)))
.ToList());
}
}
@@ -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<BayaApiFactory>
{
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);
}
}
@@ -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<BayaApiFactory>
{
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);
}
}
@@ -0,0 +1,34 @@
using System.Net;
using System.Net.Http.Json;
namespace Baya.Test.Api;
public class PublicTrustBadgeApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[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);
}
}

Some files were not shown because too many files have changed in this diff Show More