add build development phases
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
# Backend Phase 6 — Nurse verification & credentials (mocked vendors)
|
||||
|
||||
> **Mission:** build the **trust engine** — the platform's entire brand. A **data-driven**, platform-owned
|
||||
> verification pipeline (steps are *rows*, not an enum) that mixes automated KYC-vendor checks with manual
|
||||
> admin document review, rolls every per-step outcome into one authoritative `nurse_verifications.status`,
|
||||
> maintains a structured, queryable **credential registry** (license numbers, authority, holder name,
|
||||
> issue/expiry), and **transactionally flips `nurse_profiles.is_verified`** the moment every required step
|
||||
> passes (and reverses it on suspension). Documents live in object storage behind signed URLs — only
|
||||
> metadata + an integrity hash touch the DB. Every external vendor (Shahkar, identity KYC/liveness, the
|
||||
> MoH/INO/criminal-record portals, IBAN ownership) is **mocked behind a DI seam** so the real swap is
|
||||
> implementation-only. After this phase a nurse can finally become bookable.
|
||||
>
|
||||
> **Track:** backend · **Depends on:** [b3](./backend-phase-3.md) (`nurse_profiles`, `nurse_bank_accounts`, `IBankAccountOwnershipVerifier`), [b1](./backend-phase-1.md) (`support_alerts`, the typed config accessor), [b0](./backend-phase-0.md) (`IObjectStorage`, `IFieldEncryptor`, `ICurrentUser`, audit interceptor) · **Unlocks:** search visibility ([b7](./backend-phase-7.md)); frontend **f5-b6**
|
||||
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context — where this sits
|
||||
|
||||
This is **backend phase b6**, the verification leg of the build. Balinyaar is a *trust-first* home-nursing
|
||||
marketplace; the product docs are blunt that "verified trust is your **entire brand**." Vetting is
|
||||
**platform-owned, non-optional, and performed at the authoritative source** — a nurse's service variants
|
||||
become bookable **only after every required verification step passes**. This phase builds that gate: the
|
||||
6-step pipeline, the admin review queue, the structured credential registry that powers the public trust
|
||||
badge and renewal alerts, and the **one transaction** that flips `nurse_profiles.is_verified`. It sits
|
||||
between catalog ([b5](./backend-phase-5.md), which built the bookable service variants) and search
|
||||
([b7](./backend-phase-7.md), which surfaces only verified nurses) — and it is the hard prerequisite that
|
||||
makes the `nurse_search_index.is_searchable` projection meaningful.
|
||||
|
||||
**What already exists (do not rebuild) — built by prior phases:**
|
||||
- **`nurse_profiles`** — [b3](./backend-phase-3.md) built the nurse extension off `users` (1:1 UNIQUE
|
||||
`user_id`), carrying the **guarded `is_verified` BIT DEFAULT 0** (no public setter — this phase owns the
|
||||
only write), `is_accepting_bookings`, the denormalized rating aggregates, and soft-delete. **The legacy
|
||||
`verification_status` column was deliberately CUT** — `nurse_verifications.status` is the sole source of
|
||||
verification truth. Do **not** reintroduce a second copy.
|
||||
- **`nurse_bank_accounts`** — [b3](./backend-phase-3.md) built the payout-IBAN table (`iban` enc,
|
||||
`iban_hash` UNIQUE, `is_primary` filtered-UNIQUE per nurse, `is_verified`, `matched_national_id`,
|
||||
`account_holder_from_bank`, `ownership_vendor_ref`, `verified_by_admin_id`) and the
|
||||
**`IBankAccountOwnershipVerifier`** seam (استعلام شبا / Sheba inquiry). This phase's
|
||||
`bank_account_verification` step **couples to that table and reuses that seam** — it does not re-register
|
||||
or re-verify bank accounts.
|
||||
- **`users` / identity** — [b2](./backend-phase-2.md)/[b3](./backend-phase-3.md): `users.national_id` (enc,
|
||||
**NULL until KYC passes**), `users.gender`, `shahkar_verified_at`/`national_id_verified_at` (reset to NULL
|
||||
on phone change), and admin RBAC roles for the review queue. The identity-KYC step **populates
|
||||
`users.national_id`** on pass; Shahkar and IBAN ownership both compare against that verified national-ID.
|
||||
- **`support_alerts`** — [b1](./backend-phase-1.md) built the alert table + the **raise API** (built early
|
||||
precisely because verification raises expiry/renewal/shared-SIM alerts). This phase **raises** alerts; it
|
||||
does not build the table.
|
||||
- **The typed, cached config accessor + `platform_configs`** — [b1](./backend-phase-1.md). Read review-SLA
|
||||
hints, expiry-scan cadence, and any verification thresholds through it; never hardcode.
|
||||
- **`IObjectStorage`** — [b0](./backend-phase-0.md): presigned/streamed put/get/delete keyed by an opaque
|
||||
storage key, returning a retrievable URL. Verification documents store bytes here. **Reuse it.**
|
||||
- **`IFieldEncryptor`** — [b0](./backend-phase-0.md): `Encrypt`/`Decrypt`/`Hash`. `credential_number` is
|
||||
encrypted PII through this seam. **Reuse it.**
|
||||
- The b0 foundation: REST surface, `BaseController`, `OperationResult<T>`, CQRS via
|
||||
**`martinothamar/Mediator`** (not MediatR), Mapster, FluentValidation + `ValidateCommandBehavior`,
|
||||
`ICurrentUser` + the audit-field SaveChanges interceptor, rate limiting, `IDateTimeProvider`,
|
||||
`INotificationDispatcher` (in-app write landed via b1).
|
||||
|
||||
**What this phase introduces:** the five verification tables + seed, the nurse/admin capabilities, the
|
||||
guarded `is_verified` flip transaction, the credential-expiry scanner, the public trust badge — and **three
|
||||
new seams** (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`). The **scheduled cron** that
|
||||
drives the expiry scan is DEFERRED — the scan is admin/manually triggered now (see §3.3).
|
||||
|
||||
## 2. Required reading (do this first)
|
||||
|
||||
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
|
||||
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) — especially
|
||||
the *Persistence* (encrypted PII through the field encryptor; one config per entity; projected/paginated
|
||||
reads) and *Architecture* (seams in Application, mocks in Infrastructure) blocks.
|
||||
- [`product/business/02-nurse-verification.md`](../../../product/business/02-nurse-verification.md) — **the
|
||||
business rules**: the six steps and what each proves, the data-driven pipeline, the structured credential
|
||||
registry, continuous monitoring (phone-change re-run, expiry re-verify), and the MVP-vs-DEFERRED line
|
||||
(automated MoH/INO lookup, ML fraud scoring, and the liability-insurance step are all DEFERRED).
|
||||
- [`product/data-model/04-verification-and-credentials.md`](../../../product/data-model/04-verification-and-credentials.md)
|
||||
— **the canonical schema** for the five tables. Mirror these field names exactly (especially the
|
||||
`nurse_credentials` column list and the `UNIQUE` constraints).
|
||||
- [`product/research/verification.md`](../../../product/research/verification.md) — *why* the design is what
|
||||
it is: MoH پروانه صلاحیت حرفهای is the single most important credential and **already bundles the
|
||||
criminal-record screen**; identity is the *easy* layer (buy a KYC vendor, don't build); license check is
|
||||
**manual** today because no public B2B API exists; the criminal record is **consent-gated to the person**
|
||||
(nurse-uploaded + re-requested). This is the source of the "manual today, API later" seam shape.
|
||||
- The **identity digest** for the cross-domain invariants:
|
||||
`dm_identity_geo_services_verif.md` (`is_verified` is write-guarded; `national_id` NULL until KYC;
|
||||
`shahkar_verified_at` resets on phone change; `nurse_search_index.is_searchable` depends on `is_verified`).
|
||||
- **Code to mirror:** [b3](./backend-phase-3.md)'s `nurse_bank_accounts` config + the
|
||||
`IBankAccountOwnershipVerifier` usage; [b1](./backend-phase-1.md)'s `support_alerts` raise API + the typed
|
||||
config accessor + the seed-migration pattern (for `verification_step_types`); [b0](./backend-phase-0.md)'s
|
||||
`IObjectStorage`/`IFieldEncryptor` usage and a `ServiceConfiguration/` seam registration; any existing
|
||||
`Features/<Area>/{Commands|Queries}/**` for handler structure and `OperationResult` returns.
|
||||
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
|
||||
(envelope, routing) — money is not central here but follow the format.
|
||||
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-3.md`,
|
||||
`…-1.md`, `…-0.md`, and `reports/mocks-registry.md` (the `IObjectStorage` /
|
||||
`IBankAccountOwnershipVerifier` / `IFieldEncryptor` rows you reuse, and where to add the three new ones).
|
||||
|
||||
## 3. Scope — build this
|
||||
|
||||
Features live under `Baya.Application/Features/Verification/{Commands|Queries}/<Name>/`; entities in
|
||||
`Baya.Domain/Entities/Verification/`; one `IEntityTypeConfiguration<T>` per entity in
|
||||
`Persistence/Configuration/VerificationConfig/`; one EF migration for the five tables + one **seed
|
||||
migration** for `verification_step_types`.
|
||||
|
||||
### 3.1 Entities + migration
|
||||
|
||||
**`nurse_verifications`** [CORE] — the master per-nurse record; **the SOLE source of verification truth**.
|
||||
- Fields: `id`, `nurse_id` (FK `nurse_profiles`, **UNIQUE** → 1:1), `status` (enum, see below),
|
||||
`submitted_at`, `approved_at`, `rejected_at`, `suspended_at` (all nullable), `rejection_reason` (nullable),
|
||||
`reviewed_by_admin_id` (FK `users` nullable), `internal_notes` (nullable), audit fields, soft-delete.
|
||||
- Relations: 1:1 → `nurse_profiles`; 1:N → `verification_steps`.
|
||||
|
||||
**`verification_step_types`** [CORE] — the admin catalog of pipeline steps (data-driven, not an enum).
|
||||
- Fields: `id`, `code` (NVARCHAR, **UNIQUE**, stable machine code), `display_name`, `description`,
|
||||
`is_required` (BIT), `is_automated` (BIT), `automation_provider` (NVARCHAR nullable — e.g. `shahkar`,
|
||||
`identity_kyc_vendor`), `sort_order` (int), `is_active` (BIT), audit fields.
|
||||
- **SEED** (via the seed migration) exactly these six stable codes:
|
||||
`identity_kyc` (automated), `shahkar_match` (automated), `moh_competency_license` (manual today),
|
||||
`ino_membership` (manual), `criminal_record` (manual, time-limited), `bank_account_verification`
|
||||
(automated, couples to b3). A new regulatory step (e.g. professional-liability insurance) is one INSERT —
|
||||
**never** hardcode the six in a C# enum.
|
||||
- Relations: 1:N → `verification_steps`.
|
||||
|
||||
**`verification_steps`** [CORE] — one row per step per nurse.
|
||||
- Fields: `id`, `nurse_verification_id` (FK), `step_type_id` (FK), `status` (enum, see below),
|
||||
`external_response_json` (NVARCHAR(MAX) nullable — raw KYC-vendor response for audit),
|
||||
`expires_at` (DATETIME2 nullable — for time-limited steps), **`is_automated` (BIT — *snapshotted* from the
|
||||
step type at seed time, read this, never the live step-type)**, `started_at`/`completed_at` (nullable),
|
||||
`failure_reason` (nullable), audit fields. **`UNIQUE(nurse_verification_id, step_type_id)`**.
|
||||
- **On expiry → revert to `pending` + `RaiseSupportAlert`** (handled by the scanner, §3.2).
|
||||
- Relations: N:1 → `nurse_verifications`, `verification_step_types`; 1:N → `verification_documents`.
|
||||
|
||||
**`verification_documents`** [CORE] — **metadata only; bytes never in the DB**.
|
||||
- Fields: `id`, `step_id` (FK `verification_steps`), `object_storage_key` (NVARCHAR — the opaque
|
||||
`IObjectStorage` key), `integrity_hash` (NVARCHAR(64) — content hash to detect tampering/swap),
|
||||
`content_type`, `file_size_bytes` (BIGINT), `original_file_name` (nullable), `uploaded_by_user_id`
|
||||
(FK `users`), audit fields, soft-delete.
|
||||
- **No PII bytes, ever** — the file lives in object storage behind a **signed URL**; the DB row is metadata.
|
||||
- Relations: N:1 → `verification_steps`.
|
||||
|
||||
**`nurse_credentials`** [MVP] — the structured, queryable credential registry (powers badge + renewal).
|
||||
- Fields (mirror [`04-verification-and-credentials.md`](../../../product/data-model/04-verification-and-credentials.md)
|
||||
exactly):
|
||||
`id`, `nurse_id` (FK `nurse_profiles`), `credential_type` (NVARCHAR — `moh_competency_license` /
|
||||
`ino_membership` / `criminal_record`), `credential_number` (**NVARCHAR(100) encrypted** via
|
||||
`IFieldEncryptor`), `holder_name_snapshot` (NVARCHAR(200) — name as printed, for ID cross-check),
|
||||
`issuing_authority` (NVARCHAR(200)), `issued_at` (DATE nullable), `expires_at` (DATE nullable — drives
|
||||
renewal alerts), `verification_source` (NVARCHAR(300) nullable — portal URL/method),
|
||||
`verification_method` (NVARCHAR(20) — `manual` / `portal` / `api`), `verified_by_admin_id` (FK `users`
|
||||
nullable), audit fields, soft-delete.
|
||||
- Relations: N:1 → `nurse_profiles`; cross-referenced by the relevant `verification_steps`.
|
||||
|
||||
**Status enums** (define as proper enums; persist per project convention):
|
||||
- `VerificationStatus`: `not_started` | `pending` | `in_review` | `approved` | `rejected` | `suspended`.
|
||||
- `VerificationStepStatus`: `not_started` | `pending` | `in_review` | `passed` | `failed` | `expired`.
|
||||
(`pending` = awaiting submission/automation; `in_review` = awaiting manual admin decision.)
|
||||
|
||||
### 3.2 Commands & queries (CQRS, `OperationResult`, never throw for expected failures)
|
||||
|
||||
| Capability | Type | Route | What it does |
|
||||
| --- | --- | --- | --- |
|
||||
| **`AdminUpsertStepTypeCommand`** / **`AdminListStepTypesQuery`** / **`AdminDeactivateStepTypeCommand`** | Cmd/Query | `POST`/`GET`/`DELETE api/v1/admin_verification_step_types[/{id}]` | Admin CRUD over the step-type catalog (`code` immutable once used, `is_required`/`is_automated`/`automation_provider`/`sort_order`/`is_active`). Adding a step = one row. Cached (read-heavy reference data) with invalidation on write. |
|
||||
| **`SubmitNurseVerificationCommand`** | Command | `POST api/v1/nurse_verification/submit` | For the signed-in nurse (tenancy via `ICurrentUser`): upsert a `nurse_verifications` row, set `status='pending'` + `submitted_at`, and **seed one `verification_steps` row per *active required* `verification_step_types`** (status `pending`/`not_started`, **snapshotting `is_automated`** onto each step). Idempotent: re-submitting does not duplicate steps. |
|
||||
| **`GetNurseVerificationStatusQuery`** | Query | `GET api/v1/nurse_verification` | The nurse's aggregate `status` + per-step list (code, display, status, `expires_at`, failure reason, whether automated) + a "what's blocking bookability" summary. Projected (`AsNoTracking` + `.Select`), tenancy-scoped. Feeds f5's checklist. |
|
||||
| **`RequestDocumentUploadUrlCommand`** | Command | `POST api/v1/nurse_verification/steps/{stepId}/upload_url` | Returns a **signed PUT URL** from `IObjectStorage` for a manual-evidence step (MoH license PDF, INO card, عدم سوء پیشینه). Validates the step belongs to the nurse and accepts uploads. Returns the `object_storage_key` the client echoes back on confirm. |
|
||||
| **`ConfirmDocumentUploadCommand`** | Command | `POST api/v1/nurse_verification/steps/{stepId}/documents` | After the client uploads to the signed URL: persist a `verification_documents` **metadata row** (key, `integrity_hash` computed/echoed, content type, size). **Bytes never enter the DB.** Moves the step to `in_review` if it's a manual step. |
|
||||
| **`RunIdentityKycCommand`** | Command | `POST api/v1/nurse_verification/steps/identity_kyc/run` | Calls **`IIdentityKycProvider`** (national-ID validity + name match + liveness). Persists `external_response_json`; on pass **populates `users.national_id`** + `national_id_verified_at` and sets the step `passed`; on fail sets `failed` + `failure_reason`. Re-aggregates (§ `AggregateAndFinalize`). |
|
||||
| **`RunShahkarMatchCommand`** | Command | `POST api/v1/nurse_verification/steps/shahkar_match/run` | Calls **`IShahkarVerifier`** to bind the login SIM ↔ the **verified** `users.national_id`. Persists `external_response_json` + sets `shahkar_verified_at` on pass. **Shared-SIM is an explicit handled failure state** → step `failed` + `RaiseSupportAlert(shared_sim)`. Requires identity KYC passed first (national-ID present). |
|
||||
| **`RunBankAccountVerificationCommand`** | Command | `POST api/v1/nurse_verification/steps/bank_account_verification/run` | Reuses **`IBankAccountOwnershipVerifier`** (b3) on the nurse's primary `nurse_bank_accounts`: holder national-ID must equal the verified nurse national-ID. On match sets the bank account's `matched_national_id=1` + the step `passed`; on mismatch `failed` (money-mule guard). |
|
||||
| **`AdminListPendingStepsQuery`** | Query | `GET api/v1/admin_verifications?status=in_review&page=&page_size=` | The manual-review worklist (MoH/INO/criminal steps in `in_review`): nurse, step code, submitted docs (with **signed GET URLs** via `IObjectStorage`). Projected + paginated. |
|
||||
| **`AdminGetVerificationDetailQuery`** | Query | `GET api/v1/admin_verifications/{nurseVerificationId}` | Full per-nurse detail for the doc-viewer: all steps, documents (signed URLs), existing credentials, identity name (for cross-check). Projected. |
|
||||
| **`AdminReviewStepCommand`** | Command | `POST api/v1/admin_verifications/steps/{stepId}/decide` | Admin **passes or rejects** a manual step with a required reason on reject. **On pass of a credential-bearing step (MoH/INO/criminal): record a `nurse_credentials` row** (encrypted `credential_number`, `holder_name_snapshot` **cross-checked against the identity name**, authority, issued/expires, `verification_method='manual'`, `verified_by_admin_id`). Writes an audit-log entry (b1 audit interceptor + an explicit decision record). Re-aggregates. |
|
||||
| **`AggregateAndFinalize`** | Command (internal step, called after every outcome write) | — | Re-reads all *required* steps. Sets `nurse_verifications.status` (`pending`→`in_review` when any step is `in_review`; `rejected` if any required step `failed`; **`approved` only when every required step is `passed`**). **On all-passed: flip `nurse_profiles.is_verified = 1` in the SAME transaction**, set `approved_at`. Idempotent — re-running an already-approved verification is a no-op. |
|
||||
| **`AdminSuspendVerificationCommand`** | Command | `POST api/v1/admin_verifications/{nurseVerificationId}/suspend` | Sets `status='suspended'` + `suspended_at` and **reverses the flip — `nurse_profiles.is_verified = 0` in the same transaction** (un-publishing the nurse from search via b7's projection hook). Records reason + admin. |
|
||||
| **`ScanExpiringCredentialsCommand`** | Command | `POST api/v1/admin_verifications/scan_expiring` (admin-triggered; cron DEFERRED) | Scans `nurse_credentials.expires_at` and the matching time-limited `verification_steps.expires_at` (criminal-record especially). For each lapsed credential: **revert the step to `pending`/`expired`, raise a `support_alert` + a renewal-prompt notification**, and if a *required* credential lapsed, re-aggregate (which can drop `is_verified` via suspension semantics). Paginated/batched. |
|
||||
| **`GetVerifiedTrustBadgeQuery`** | Query | `GET api/v1/nurses/{nurseId}/trust_badge` (public) | The public "verified" badge sourced from approved `nurse_verifications` + non-expired `nurse_credentials` (credential **types** held, e.g. "MoH license · INO member", **never the encrypted numbers**). Projected, cached. Feeds the public nurse profile (f6). |
|
||||
|
||||
- **Controllers:** `NurseVerificationController` (nurse policy, tenancy-scoped),
|
||||
`AdminVerificationController` (admin policy, sensitive — **rate-limited**), and the public
|
||||
trust-badge action (anonymous, read-only). All `sealed : BaseController`, inject `ISender`, return
|
||||
`base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes, `CancellationToken` threaded.
|
||||
- **Validators:** FluentValidation on the submit/run/decide/upload commands (national-ID format on identity
|
||||
run; required rejection reason on a reject decision; required `expires_at` on the criminal-record
|
||||
credential; step ownership/tenancy on the nurse-side commands).
|
||||
|
||||
### 3.3 DEFERRED (build the seam/flag, not the feature)
|
||||
- **The scheduled expiry-scan cron** (`CredentialExpiryScannerJob`, emulating Nursys e-Notify cadence) —
|
||||
DEFERRED. The scan logic ships now as `ScanExpiringCredentialsCommand`, admin-triggered; leave a clean
|
||||
entry point + a `verification_expiry_scan_cadence` config key. (Roadmap: a hosted scheduler.)
|
||||
- **Automated MoH/INO license lookup** — DEFERRED behind `ICredentialVerifier` (`verification_method='api'`);
|
||||
the manual review path is the default impl today. Build the seam, not the API client.
|
||||
- **`fraud_flags` / ML fraud scoring** and the **professional-liability-insurance step** — DEFERRED
|
||||
(`fraud_flags` modeled-but-inactive; the insurance step is "addable as a row when required"). Do not build.
|
||||
- **Customer national-ID KYC** — out of scope here (the column stays unused per b3). Do not gate on it.
|
||||
|
||||
## 4. Mocks & seams in this phase
|
||||
|
||||
| Seam | Owner | Mock behaviour | Registry |
|
||||
| --- | --- | --- | --- |
|
||||
| **`IShahkarVerifier`** | **introduced here** | `MatchAsync(phone, nationalId, ct)` returns a deterministic result + a fake `vendor_ref` + an `external_response_json` blob: **pass** when phone+national-ID match a seed map; **force shared-SIM failure** for a known test number (the explicit handled state). No real Shahkar call. | **add a new row** (🟡) |
|
||||
| **`IIdentityKycProvider`** | **introduced here** | `VerifyAsync(nationalId, livenessPayload, ct)` returns deterministic **pass/fail by test national-ID** + a fake vendor ref + `external_response_json`; pass implies a name-match string used for the credential cross-check. No real liveness/OCR. | **add a new row** (🟡) |
|
||||
| **`ICredentialVerifier`** | **introduced here** | The **manual-admin default impl** *is* the mock: `Verify(credentialType, …)` returns `RequiresManualReview` today (`verification_method='manual'`), shaped so an `api`/`portal` impl drops in later for MoH/INO. No portal call. | **add a new row** (🟡) |
|
||||
| `IBankAccountOwnershipVerifier` | reuse from **b3** | Sheba inquiry mock; returns holder national-ID = entered one (or mismatch for a test IBAN). Used by `bank_account_verification`. | reuse row |
|
||||
| `IObjectStorage` | reuse from **b0** | local-disk/in-memory blob store; signed PUT/GET URLs for `verification_documents`. | reuse row |
|
||||
| `IFieldEncryptor` | reuse from **b0** | local symmetric key; encrypts `credential_number`, never logs plaintext. | reuse row |
|
||||
| `ICacheService` | reuse from **b0/b1** | in-memory; behind the typed config accessor + the step-type/badge caches. | reuse row |
|
||||
|
||||
Each new seam is an **Application-layer interface** with an **Infrastructure mock** and **DI registration**
|
||||
via a `ServiceConfiguration/` extension (real impl is config-selected later — **never** an `if (mock)` branch
|
||||
in a handler). Persist the raw vendor response in `external_response_json` so the audit trail survives the
|
||||
swap. Append the three new rows to
|
||||
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
|
||||
(seam, file, what's faked, config keys, **step-by-step how to make it real** — which Iranian KYC vendor
|
||||
[Finnotech / U-ID / Jibbit / Farashensa / Verify / Kavoshak], which methods to implement, what to test; and
|
||||
for `ICredentialVerifier`, the note that MoH/INO have **no public B2B API** so it stays manual until one
|
||||
appears).
|
||||
|
||||
## 5. Critical rules you must not get wrong
|
||||
|
||||
- **`nurse_verifications.status` is the SINGLE source of truth.** `nurse_profiles.verification_status` no
|
||||
longer exists — **do not reintroduce it** or any second copy of verification state. Every read of "is this
|
||||
nurse verified for *state* purposes" goes through `nurse_verifications.status`; the only derived boolean is
|
||||
`nurse_profiles.is_verified`, written solely by the flip below.
|
||||
- **Flip `nurse_profiles.is_verified = 1` ONLY inside the all-passed transaction** that confirms every
|
||||
*required* step is `passed` (`AggregateAndFinalize`). Never set it from a controller, a partial pass, or an
|
||||
out-of-band update. **Reverse it (`is_verified = 0`) in the same transaction on suspension** — a suspended
|
||||
or lapsed nurse must immediately stop being bookable/searchable.
|
||||
- **A nurse is bookable only after all required steps pass.** Any failing/pending/expired required step keeps
|
||||
every service variant unbookable. This is the gate b7's `nurse_search_index.is_searchable` reads.
|
||||
- **The pipeline is data-driven — read required steps from `verification_step_types`, never a code enum.** A
|
||||
new step is an INSERT. Seed the six codes; don't branch on them in a `switch` that would break when a row
|
||||
is added.
|
||||
- **`step_type.is_automated` is SNAPSHOTTED onto `verification_steps` at seed time — read the snapshot, not
|
||||
the live step type.** Historical records must survive later catalog edits (an admin toggling a step's
|
||||
automation must not rewrite the meaning of past verifications).
|
||||
- **Expiring credentials auto-revert the step.** On `expires_at` lapse (criminal-record especially), revert
|
||||
the step to `pending`/`expired`, **raise a `support_alert`**, send a renewal prompt, and if a *required*
|
||||
credential lapsed, re-gate bookability. A lapsed certificate must **never** silently keep a nurse verified.
|
||||
- **`users.national_id` is populated only after the identity step passes**, and every downstream comparison
|
||||
(Shahkar match, IBAN ownership, credential holder-name cross-check) compares against **that** verified
|
||||
national-ID. An unverified registration must never look KYC-complete.
|
||||
- **IBAN ownership gates the first payout, not admin eyeballing.** `bank_account_verification` reuses b3's
|
||||
`IBankAccountOwnershipVerifier`; the holder national-ID must equal the verified nurse national-ID
|
||||
(money-mule prevention). Never pass the step on a mismatch.
|
||||
- **`verification_documents` store metadata only — bytes live in object storage behind signed URLs, with an
|
||||
integrity hash; files are never public.** No document byte stream is ever written to or returned from the
|
||||
DB; access is always a short-lived signed URL.
|
||||
- **`credential_number` is encrypted PII** (through `IFieldEncryptor`) and **`holder_name_snapshot` must be
|
||||
cross-checked against the nurse's identity name** before a credential is recorded as verified. Never trust
|
||||
an uploaded file alone — forgery (the "imposter nurse") is the documented attack.
|
||||
- **Shared-SIM is an explicit handled state**, not an undefined edge: fail Shahkar gracefully with a clear,
|
||||
non-accusatory reason and raise a `support_alert`. Re-run Shahkar on phone change (`shahkar_verified_at`
|
||||
resets to NULL upstream).
|
||||
- **Every manual admin decision is auditable** (the b1 audit interceptor + an explicit decision record) for
|
||||
defensibility — verification is platform-owned and must never be marketed as a check not actually performed.
|
||||
- **All vendor steps are mocked now — but behind real DI seams.** No mock behaviour baked into call sites;
|
||||
the swap is implementation-only and config-selected.
|
||||
- **Tenancy:** every nurse-side command/query is scoped to the authenticated nurse via `ICurrentUser`; a
|
||||
nurse can never read or act on another nurse's verification. Admin endpoints sit behind the admin policy and
|
||||
are rate-limited. The public trust badge exposes **credential types held, never numbers**.
|
||||
|
||||
## 6. Definition of Done
|
||||
|
||||
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
|
||||
- [ ] The five tables (`nurse_verifications`, `verification_step_types`, `verification_steps`,
|
||||
`verification_documents`, `nurse_credentials`) exist via one migration, each with its
|
||||
`IEntityTypeConfiguration<T>`, the `nurse_id` UNIQUE (1:1) on `nurse_verifications`, the
|
||||
`UNIQUE(nurse_verification_id, step_type_id)` on `verification_steps`, the **snapshot `is_automated`**
|
||||
column, encrypted `credential_number`, and soft-delete/audit wiring.
|
||||
- [ ] The seed migration loads the **six** `verification_step_types` with the exact stable codes and the
|
||||
correct `is_required`/`is_automated` flags.
|
||||
- [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads,
|
||||
validators), with `NurseVerificationController`, `AdminVerificationController`, and the public badge.
|
||||
- [ ] **`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`** introduced (Application
|
||||
interfaces, Infrastructure mocks, DI via a `ServiceConfiguration/` extension, config-selected); the b3
|
||||
`IBankAccountOwnershipVerifier` and b0 `IObjectStorage`/`IFieldEncryptor` seams reused (not redefined).
|
||||
No `if (mock)` in handlers.
|
||||
- [ ] The **flip transaction** is correct: `is_verified=1` only on all-required-passed, in one transaction;
|
||||
`is_verified=0` on suspension, in one transaction; the expiry scan reverts steps + raises alerts.
|
||||
- [ ] Handler unit tests (NSubstitute) for: step seeding from required step-types, the snapshot of
|
||||
`is_automated`, the automated-step pass/fail (Shahkar + identity KYC mocks), the manual-review →
|
||||
credential-record path with holder-name cross-check, the all-passed flip, the suspension reverse, and
|
||||
the expiry revert + alert. ≥1 `WebApplicationFactory` integration test per controller (happy path, 401,
|
||||
validation 400). `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green.
|
||||
- [ ] The `Baya.Application/Features/Verification/**` area is added to the **Project map** in
|
||||
`server/CLAUDE.md`; the three new seams noted where seams are documented.
|
||||
- [ ] The contract `dev/contracts/domains/verification.md` is written and the `swagger.json` snapshot
|
||||
republished.
|
||||
|
||||
## 7. How to test (what a human can verify after this phase)
|
||||
|
||||
Prereqs: a nurse user with a `nurse_profiles` row (b3) and (for the bank step) a primary
|
||||
`nurse_bank_accounts` row; an admin user. The seam mocks are deterministic — use the seed/test national-IDs.
|
||||
|
||||
1. **Seed step-types** — `GET api/v1/admin_verification_step_types` → the **six** seeded codes appear with
|
||||
correct `is_required`/`is_automated` flags. Add a 7th via `POST` → it persists (proves data-driven).
|
||||
2. **Submit** — as the nurse, `POST api/v1/nurse_verification/submit` → a `nurse_verifications` row in
|
||||
`pending` and **one `verification_steps` row per required step**, each with `is_automated` snapshotted.
|
||||
`GET api/v1/nurse_verification` shows the checklist + "what's blocking bookability".
|
||||
3. **Upload a document** — `POST …/steps/{stepId}/upload_url` returns a signed URL; PUT a file to it (mock
|
||||
storage); `POST …/steps/{stepId}/documents` → a `verification_documents` **metadata row** with the
|
||||
`object_storage_key` + `integrity_hash` stored; **no bytes in the DB**; the manual step moves to `in_review`.
|
||||
4. **Run automated Shahkar / identity KYC** — `POST …/steps/identity_kyc/run` (pass test national-ID) →
|
||||
step `passed`, `users.national_id` populated, `external_response_json` stored; then
|
||||
`POST …/steps/shahkar_match/run` → `passed` (and `shahkar_verified_at` set). Run Shahkar with the
|
||||
**shared-SIM** test number → step `failed` + a `support_alert` raised.
|
||||
5. **Run bank-account verification** — `POST …/steps/bank_account_verification/run` (matching test IBAN) →
|
||||
`passed` + the account's `matched_national_id=1`; with the **mismatch** test IBAN → `failed`.
|
||||
6. **Admin approves manual steps** — `GET api/v1/admin_verifications?status=in_review` shows the queue;
|
||||
`POST api/v1/admin_verifications/steps/{stepId}/decide` (pass) on MoH + INO + criminal-record →
|
||||
each records a `nurse_credentials` row (encrypted number, holder-name cross-check, expiry on the
|
||||
criminal record). Reject one with a reason → step `failed` and the nurse sees the reason.
|
||||
7. **All steps passed flips `is_verified` in one transaction** — once every required step is `passed`,
|
||||
`nurse_verifications.status='approved'` **and** `nurse_profiles.is_verified=1` — confirm both changed
|
||||
together (verify there is no in-between state where the verification is approved but `is_verified` is still 0).
|
||||
8. **Suspend reverses it** — `POST api/v1/admin_verifications/{id}/suspend` → `status='suspended'` **and**
|
||||
`is_verified=0` together.
|
||||
9. **Expire a credential** — set a `nurse_credentials.expires_at` (criminal record) in the past, run
|
||||
`POST api/v1/admin_verifications/scan_expiring` → the matching step reverts to `pending`/`expired`, a
|
||||
`support_alert` is raised, and (since it's required) the nurse is re-gated.
|
||||
10. **Public trust badge** — `GET api/v1/nurses/{nurseId}/trust_badge` → shows the verified status +
|
||||
credential **types** held; **no credential numbers** are ever returned.
|
||||
|
||||
## 8. Hand off & document (close the phase)
|
||||
|
||||
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
|
||||
`Features/Verification/**` area + the three new seams). If you discover/confirm a rule the product docs
|
||||
don't capture (e.g. the exact `in_review` vs `pending` step semantics, the renewal-prompt notification, or
|
||||
the trust-badge "types not numbers" rule), record it in
|
||||
[`product/business/02-nurse-verification.md`](../../../product/business/02-nurse-verification.md) /
|
||||
[`product/data-model/04-verification-and-credentials.md`](../../../product/data-model/04-verification-and-credentials.md)
|
||||
— don't invent rules. Note the new seam family in `server/CONVENTIONS.md` if it establishes a reusable pattern.
|
||||
- **Contract to write:** **`dev/contracts/domains/verification.md`** (per
|
||||
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the nurse endpoints
|
||||
(submit, status, upload-url, confirm-document, run identity/Shahkar/bank steps), the admin endpoints
|
||||
(step-type CRUD, pending-steps queue, detail, decide, suspend, scan-expiring), and the public trust-badge
|
||||
endpoint; the `VerificationStatus` / `VerificationStepStatus` enums and the six step-type `code`s; the
|
||||
step / step-type / document (signed-URL) / credential DTO shapes (**`credential_number` never serialized**;
|
||||
badge shows **types not numbers**); auth/tenancy/rate-limit notes; the side effects (`is_verified` flip,
|
||||
`support_alerts` raised, `users.national_id` populated). Republish the `swagger.json` snapshot per
|
||||
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f5-b6** consumes.
|
||||
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-6.md` (the
|
||||
verification pipeline is live, what f5 can now build — the nurse verification checklist, identity submit,
|
||||
document upload, credentials form, under-review state, trust badge; which endpoints/contracts are live;
|
||||
that all vendors are mocked behind `IShahkarVerifier`/`IIdentityKycProvider`/`ICredentialVerifier` and the
|
||||
reused b3 bank seam). Append to `backend/STATUS.md`, write
|
||||
`dev/shared-working-context/reports/backend-phase-6-report.md` (what was built, **what is now testable and
|
||||
exactly how** per §7, what is mocked + how to make it real, contracts produced, follow-ups: the expiry-scan
|
||||
cron, automated MoH/INO lookup, `fraud_flags`), and update
|
||||
`dev/shared-working-context/reports/mocks-registry.md` (the three new seam rows → 🟡; the reused rows noted).
|
||||
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — `status` is the
|
||||
single source of truth (no `verification_status` copy), the guarded `is_verified` flip/reverse transaction,
|
||||
the `is_automated` snapshot rule, the data-driven step catalog, the expiry-revert + alert flow, documents-as-
|
||||
metadata-only, and the three new vendor seams — with a one-line pointer in `MEMORY.md`.
|
||||
Reference in New Issue
Block a user