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:
@@ -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 | 🟡 |
|
||||
|
||||
Reference in New Issue
Block a user