342 lines
31 KiB
Markdown
342 lines
31 KiB
Markdown
# Backend Phase 3 — Identity: profiles, patients & nurse bank accounts
|
|
|
|
> **Mission:** put the *people* behind the accounts. On top of the bare `users`/sessions/auth spine that
|
|
> b2 shipped, build the role-specific profile extensions a marketplace actually transacts against: a
|
|
> nurse's seller profile (bio, experience, the `is_accepting_bookings` toggle, and the **guarded**
|
|
> `is_verified` flag), a customer's thin payer profile with its emergency contact, the first-class
|
|
> **patient** records a customer manages on behalf of the people who can't self-advocate, and the
|
|
> **payout bank account** that is the single place real money will one day leave the platform. Every PII
|
|
> column here is encrypted; tenancy is enforced so a customer can only ever touch their own patients; and
|
|
> the IBAN is hardened with a uniqueness hash and an automated **استعلام شبا** ownership inquiry — not an
|
|
> admin's eyeballs. This is the data catalog, verification, booking, and payouts all build directly on.
|
|
>
|
|
> **Track:** backend · **Depends on:** [b2](./backend-phase-2.md) (users/auth/OTP/sessions/roles), [b0](./backend-phase-0.md) (`IFieldEncryptor`, `ICurrentUser`, audit interceptor, REST surface, seam pattern) · **Unlocks:** geography & addresses (b4), catalog (b5), nurse verification (b6), booking (b8); frontend **f2-b3**
|
|
> **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 b3**, the second identity phase. b2 turned the inherited auth spine into a real
|
|
phone-OTP login: `users` (phone primary, `gender`, nullable `national_id`), `user_sessions` (rotation +
|
|
reuse detection), `roles`/`user_roles`, and the role-selection flow. What b2 deliberately did **not**
|
|
build is everything *attached* to a user once they pick a role — the seller profile, the payer profile,
|
|
the care recipients, and the payout destination. That is this phase. After b3, a nurse has a profile that
|
|
verification (b6) can flip to verified and that the catalog (b5) can hang priced service variants off; a
|
|
customer has patients and a profile that booking (b8) can act on behalf of; and a nurse has a bank
|
|
account that payouts (b13) can pay into. **No geography here** — saved service addresses
|
|
(`customer_addresses`) and nurse coverage areas need the province/city/district tables, so they belong to
|
|
**b4** (see §3 DEFERRED).
|
|
|
|
**What already exists (do not rebuild) — built by prior phases:**
|
|
- **`users`, auth, sessions, roles** — [b2](./backend-phase-2.md) extended `users` (phone enc UNIQUE,
|
|
`email` enc nullable, `national_id` enc nullable, `gender` NVARCHAR(10) `male`/`female`,
|
|
`national_id_verified_at`, `shahkar_verified_at`, `role`, `is_active`, `deleted_at`), built
|
|
`user_sessions` (refresh-token rotation + reuse detection), `roles`/`user_roles` (admin RBAC), the
|
|
`/auth/otp/*`, `/auth/refresh`, `/auth/logout`, `/me`, and `/me/role` endpoints, and the `ISmsSender`
|
|
seam. **Read the current user from `ICurrentUser`; resolve the customer/nurse profile off
|
|
`users.id` — never re-create the user or session machinery.**
|
|
- **`IFieldEncryptor`** — [b0](./backend-phase-0.md) introduced the field-encryption seam:
|
|
`Encrypt(string)` / `Decrypt(string)` plus a deterministic `Hash(string)` for lookup columns. **Every
|
|
PII column in this phase goes through it** (national_id was wired by b2; you add IBAN, account-holder
|
|
name, emergency contacts, medical notes, and the deterministic `iban_hash` here). Never store or log
|
|
plaintext PII.
|
|
- **`ICurrentUser` + the audit-field SaveChanges interceptor** — [b0](./backend-phase-0.md). Handlers
|
|
never set `CreatedById`/`CreatedAt`/`ModifiedById`/`ModifiedAt`; the interceptor stamps them from
|
|
`ICurrentUser`. Use `ICurrentUser.UserId` for tenancy resolution.
|
|
- **The REST surface & CQRS pipeline** — [b0](./backend-phase-0.md): versioned `sealed : BaseController`
|
|
controllers, `ISender`, `base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes, rate
|
|
limiting, `LoggingBehavior`, `ValidateCommandBehavior`, `OperationResult<T>`, Mapster, FluentValidation,
|
|
CQRS via **`martinothamar/Mediator`** (not MediatR), `IDateTimeProvider`.
|
|
- **`platform_configs` typed cached accessor** — [b1](./backend-phase-1.md). If you need a tunable (e.g. a
|
|
max-patients-per-customer guard), read it through the accessor; never hardcode.
|
|
|
|
**What this phase introduces:** the four new domain tables (`nurse_profiles`, `customer_profiles`,
|
|
`patients`, `nurse_bank_accounts`), their CRUD/management capabilities, and **one new seam —
|
|
`IBankAccountOwnershipVerifier`** (the mocked استعلام شبا IBAN-owner ↔ national-id inquiry). The
|
|
`partner_center_id` FK on `nurse_profiles` is a **forward dependency** on b15 — declare it nullable and do
|
|
not enforce or build `partner_centers` here.
|
|
|
|
## 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 *Persistence* (AsNoTracking + `.Select` projection, pagination, one
|
|
`IEntityTypeConfiguration<T>` per entity, soft-delete filters, the encrypted-PII rule) and *CQRS*.
|
|
- [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)
|
|
— **the business rules**: phone is the primary credential; the **customer ≠ patient** split (the payer
|
|
is frequently not the care recipient); patient `gender` is load-bearing for same-gender matching;
|
|
customer national-ID KYC is **DEFERRED**; a nurse is not bookable until verified.
|
|
- [`product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md)
|
|
— **the canonical schema** for `nurse_profiles`, `customer_profiles`, `patients`, `nurse_bank_accounts`.
|
|
Mirror the field names and constraints exactly: the guarded `is_verified`, the filtered uniques, the
|
|
`iban_hash` UNIQUE, the `matched_national_id`/`account_holder_from_bank`/`ownership_vendor_ref` trio, and
|
|
the **CUT** columns (`nurse_profiles.verification_status`, `response_rate`, `avg_response_time_hours`,
|
|
`profile_completion_score`; `customer_profiles.national_id_verified_at`) — **do not reintroduce them.**
|
|
- [`product/overview/platform-summary.md`](../../../product/overview/platform-summary.md) — the four
|
|
ground truths and the encrypt-PII-at-rest expectation that shapes every column here.
|
|
- **Code to mirror:** b2's `users`/`user_sessions` entity configs and the `Features/Identity/**` (or
|
|
`Features/Auth/**`) command/handler structure — your new features sit alongside them; b0's
|
|
`IFieldEncryptor` usage and any existing encrypted-column converter pattern; the existing
|
|
`IEntityTypeConfiguration<T>` files in `Persistence/Configuration/` for the filtered-index and
|
|
value-converter syntax.
|
|
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
|
|
(envelope, snake_case routes, pagination, auth) and
|
|
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) (the
|
|
**PII & sensitive fields** masking rule — IBAN returned masked, last-4 only — and `gender` as
|
|
load-bearing).
|
|
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-2.md`,
|
|
`…-after-backend-phase-0.md`, and `reports/mocks-registry.md` (the seam rows you reuse + the new one you add).
|
|
|
|
## 3. Scope — build this
|
|
|
|
All features live under `Baya.Application/Features/Identity/{Commands|Queries}/<Name>/`; entities under
|
|
`Baya.Domain/Entities/Identity/`; one `IEntityTypeConfiguration<T>` per entity in
|
|
`Persistence/Configuration/IdentityConfig/`; **one EF migration** for the four new tables. Reads use
|
|
`AsNoTracking()` + `.Select(...)` to a DTO and are paginated where they list; writes go through
|
|
`IUnitOfWork` with a single `CommitAsync` per command and never throw for expected failures (return
|
|
`OperationResult.SuccessResult/FailureResult/NotFoundResult`). Encrypted columns route through
|
|
`IFieldEncryptor` (an EF value converter or in-handler encrypt — mirror b2's national_id approach).
|
|
|
|
### 3.1 Entities + migration
|
|
|
|
**`nurse_profiles`** [CORE] — the nurse's seller profile + denormalized search/quality aggregates.
|
|
- Fields: `id` (BIGINT PK); `user_id` (FK → `users` **UNIQUE**, 1:1); `partner_center_id` (FK →
|
|
`partner_centers` **NULL** — forward dependency on b15, nullable, **no FK enforcement target built
|
|
here**); `bio`, `years_of_experience`, `education_level`, `education_field`, `specializations_json`;
|
|
**`is_verified`** (BIT NOT NULL DEFAULT 0 — **GUARDED**, see §5); `is_accepting_bookings` (BIT NOT NULL
|
|
DEFAULT 0); denormalized read-only aggregates `average_rating`, `total_reviews`,
|
|
`total_completed_bookings`; `created_at`, `updated_at`, `deleted_at` (soft-delete).
|
|
- **`is_verified` is write-guarded** — model it with **no public setter** (private/internal-set, or a
|
|
`MarkVerified()`/`MarkUnverified()` domain method only the b6 verification-confirm transaction calls).
|
|
No command, controller, or mapping in *this* phase may set it. See §5.
|
|
- **Aggregates are read-only here** — `average_rating`/`total_reviews`/`total_completed_bookings` default
|
|
to 0 and are recomputed by reviews/bookings phases; never accept them from a request body.
|
|
- **Do NOT add** `verification_status`, `response_rate`, `avg_response_time_hours`,
|
|
`profile_completion_score` (CUT — `nurse_verifications.status` from b6 is the sole verification truth).
|
|
- Soft-delete global query filter on `deleted_at IS NULL`.
|
|
- Relations: 1:1 → `users`; (forward) 1:N → `nurse_service_variants` (b5), `nurse_service_areas` (b4),
|
|
`nurse_bank_accounts` (this phase), `nurse_verifications` (b6), `bookings`/`nurse_payouts` (later).
|
|
|
|
**`customer_profiles`** [CORE] — the thin payer extension.
|
|
- Fields: `id` (BIGINT PK); `user_id` (FK → `users` **UNIQUE**, 1:1); `default_emergency_contact_name`
|
|
(**enc**); `default_emergency_contact_phone` (**enc**); `created_at`, `updated_at`.
|
|
- **Do NOT add** `national_id_verified_at` (CUT for MVP — customer national-ID KYC is DEFERRED; the column
|
|
is not even created at launch).
|
|
- Relations: 1:1 → `users`; 1:N → `patients` (this phase), `customer_addresses` (b4), `booking_requests`
|
|
(b8).
|
|
|
|
**`patients`** [CORE] — the care recipient, a first-class entity separate from the payer.
|
|
- Fields: `id` (BIGINT PK); `customer_id` (FK → `customer_profiles`); `display_name`, `first_name`,
|
|
`last_name`; `birth_date` (DATE); `gender` (NVARCHAR(10) — `male`/`female`, **required**,
|
|
same-gender-matching signal); `blood_type` (nullable); `initial_medical_notes` (**enc**); `is_active`
|
|
(BIT, archive flag); `created_at`, `updated_at`.
|
|
- **Tenancy invariant** (see §5): a patient belongs to exactly one `customer_id`; every read/write is
|
|
scoped to the signed-in customer; a patient referenced by a booking must belong to that booking's
|
|
`customer_id` (enforced fully in b8, but the ownership scoping starts here).
|
|
- Relations: N:1 → `customer_profiles`; (forward) 1:N → `booking_requests` (b8), `patient_care_records` (b14).
|
|
|
|
**`nurse_bank_accounts`** [CORE] — the payout destination; the single place real money leaves the platform.
|
|
- Fields: `id` (BIGINT PK); `nurse_id` (FK → `nurse_profiles`); `bank_name`; `account_holder_name`
|
|
(**enc**); `iban` (**enc**); **`iban_hash`** (NVARCHAR(64) — deterministic `IFieldEncryptor.Hash(iban)`,
|
|
**`UNIQUE`** so one IBAN can't silently serve two nurses); `is_primary` (BIT); **`matched_national_id`**
|
|
(BIT **NULL** — result of the استعلام شبا IBAN-owner ↔ national-id inquiry; NULL until the inquiry runs;
|
|
first payout is gated on `true` in b13); `account_holder_from_bank` (NVARCHAR(200) NULL — name the bank
|
|
returned, snapshot); `ownership_vendor_ref` (NVARCHAR(200) NULL — vendor transaction id for audit);
|
|
`is_verified` (BIT); `verified_by_admin_id` (FK → `users` NULL); `verified_at` (NULL); `created_at`,
|
|
`updated_at`.
|
|
- **Constraints:** `UNIQUE(iban_hash)`; **filtered `UNIQUE(nurse_id) WHERE is_primary = 1`** (exactly one
|
|
primary account per nurse). Both are DB indexes in the migration — the authoritative backstop, not just
|
|
handler logic.
|
|
- Relations: N:1 → `nurse_profiles`; (forward) 1:N → `nurse_payouts` (b13). The b6
|
|
`bank_account_verification` step couples to this table — build the account + inquiry here, the
|
|
verification step there.
|
|
|
|
### 3.2 Commands & queries (CQRS, `OperationResult`, never throw for expected failures)
|
|
|
|
| Capability | Type | Route | What it does |
|
|
| --- | --- | --- | --- |
|
|
| **`UpsertNurseProfileCommand`** | Command | `POST api/v1/nurse_profiles/upsert` | Creates (on first call) or updates the signed-in nurse's profile: `bio`, `years_of_experience`, `education_level`, `education_field`, `specializations_json`. Resolves `user_id` from `ICurrentUser`; rejects if the user's role isn't `nurse`. **Never** accepts `is_verified` or the aggregates. Idempotent on `user_id` (the UNIQUE 1:1 is the backstop). |
|
|
| **`SetNurseAcceptingBookingsCommand`** | Command | `POST api/v1/nurse_profiles/set_accepting_bookings` | Toggles `is_accepting_bookings` for the signed-in nurse (pause/resume without touching verified status). |
|
|
| **`GetMyNurseProfileQuery`** | Query | `GET api/v1/nurse_profiles/me` | Projects the signed-in nurse's profile incl. read-only `is_verified` + aggregates. AsNoTracking + `.Select`. |
|
|
| **`UpsertCustomerProfileCommand`** | Command | `POST api/v1/customer_profiles/upsert` | Creates/updates the signed-in customer's profile + `default_emergency_contact_name`/`_phone` (**encrypted**). Resolves `user_id` from `ICurrentUser`; rejects non-`customer` role. |
|
|
| **`GetMyCustomerProfileQuery`** | Query | `GET api/v1/customer_profiles/me` | Projects the customer's profile; emergency-contact phone returned **masked** to non-self callers per the masking rule (self sees full). |
|
|
| **`CreatePatientCommand`** | Command | `POST api/v1/patients/create` | Creates a patient under the signed-in customer (`customer_id` derived from `ICurrentUser` → `customer_profiles`, **never** from the request). Validates required `gender`, name, `birth_date`; `initial_medical_notes` encrypted. |
|
|
| **`ListPatientsQuery`** | Query | `GET api/v1/patients/list?page=&page_size=` | Lists the signed-in customer's **own** patients only (tenancy-scoped). Projected + paginated; `initial_medical_notes` returned to the owning customer only. |
|
|
| **`GetPatientQuery`** | Query | `GET api/v1/patients/get/{id}` | Returns one patient **only if it belongs to the signed-in customer** — otherwise `NotFoundResult` (don't leak existence). |
|
|
| **`UpdatePatientCommand`** | Command | `POST api/v1/patients/update/{id}` | Updates a patient the signed-in customer owns; tenancy-checked; re-encrypts changed PII. |
|
|
| **`ArchivePatientCommand`** | Command | `POST api/v1/patients/archive/{id}` | Sets `is_active = false` (soft archive, not hard delete — preserves longitudinal history for b14). Tenancy-checked. |
|
|
| **`AddNurseBankAccountCommand`** | Command | `POST api/v1/nurse_bank_accounts/add` | Adds a payout account for the signed-in nurse: `bank_name`, `account_holder_name` (enc), `iban` (enc) + computed `iban_hash`. **Rejects a duplicate IBAN** via the `iban_hash` UNIQUE (return a clean `409`/`FailureResult`, not an unhandled DB exception). Then **triggers `IBankAccountOwnershipVerifier`** (see §4) and persists `matched_national_id`, `account_holder_from_bank`, `ownership_vendor_ref`. If the nurse has no other account, this one may default to `is_primary` (subject to the filtered-unique rule). |
|
|
| **`SetPrimaryBankAccountCommand`** | Command | `POST api/v1/nurse_bank_accounts/set_primary/{id}` | Makes one of the nurse's accounts primary: clears the prior primary and sets this one, in **one transaction**, so the filtered `UNIQUE(nurse_id) WHERE is_primary=1` never trips. A duplicate-primary attempt without clearing the old one must be blocked (the filtered unique is the backstop). Tenancy-checked. |
|
|
| **`ListNurseBankAccountsQuery`** | Query | `GET api/v1/nurse_bank_accounts/list` | Lists the signed-in nurse's accounts with **masked IBAN** (last 4 only), `is_primary`, `is_verified`, `matched_national_id`. Projected. |
|
|
| **`TriggerBankAccountOwnershipInquiryCommand`** | Command | `POST api/v1/nurse_bank_accounts/verify_ownership/{id}` | Re-runs the استعلام شبا inquiry for an existing account (e.g. after a failed/NULL first attempt) and updates `matched_national_id`/`account_holder_from_bank`/`ownership_vendor_ref`. Idempotent — re-running with the same input yields the same vendor ref from the mock. |
|
|
|
|
- **Controllers:** `NurseProfilesController`, `CustomerProfilesController`, `PatientsController`,
|
|
`NurseBankAccountsController` — each `sealed : BaseController`, inject `ISender`, return
|
|
`base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes, `CancellationToken` threaded.
|
|
Authorize with the narrowest fitting policy: nurse-scoped endpoints require the nurse role, customer-scoped
|
|
the customer role; the bank-account ownership-inquiry endpoints are **rate-limited** (they call a vendor seam).
|
|
- **Validators:** FluentValidation on every input-bearing command — `gender` required + in
|
|
(`male`,`female`) on `CreatePatientCommand`; IBAN format (IR + 24 digits, Sheba) on
|
|
`AddNurseBankAccountCommand`; non-empty names; phone format on the emergency contact.
|
|
- **Mapping:** Mapster in the handler *after* the projected query — never hydrate an entity to map it.
|
|
|
|
### 3.3 DEFERRED (build the seam/flag, not the feature — with a pointer)
|
|
- **`customer_addresses`** + nurse **`nurse_service_areas`** — need the province/city/district hierarchy
|
|
and the geocoder. **DEFERRED to [b4](./backend-phase-4.md).** Do not create these tables or their CRUD
|
|
here; the digest is explicit that addresses need geography first.
|
|
- **Customer national-ID KYC** (`customer_profiles.national_id_verified_at`) — DEFERRED per
|
|
[`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)
|
|
§(c). The column is **not created** at MVP; do not collect or gate browsing/booking on it.
|
|
- **Admin staff-role grant/revoke** (`roles`/`user_roles` management UI) — the tables exist from b2; the
|
|
admin management endpoints land with the admin backoffice in [b15](./backend-phase-15.md). Don't build
|
|
them here.
|
|
- **Nurse aggregate recompute** (writing `average_rating`/`total_reviews`/`total_completed_bookings`) —
|
|
the *columns* are created here read-only; the recompute writes come from reviews/bookings phases (b9/b14).
|
|
- **`is_verified` flip** — the *guarded model* is built here; the flip itself is the b6 verification-confirm
|
|
transaction. Expose **no** write path for it in this phase.
|
|
|
|
## 4. Mocks & seams in this phase
|
|
|
|
| Seam | Owner | Mock behaviour | Registry |
|
|
| --- | --- | --- | --- |
|
|
| **`IBankAccountOwnershipVerifier`** | **introduced here** | `VerifyOwnershipAsync(iban, nurseNationalId, ct)` returns an `OwnershipInquiryResult` with `MatchedNationalId` (bool), `AccountHolderFromBank` (string), `VendorRef` (string). Mock = **deterministic fake match**: for a normal seeded/test IBAN it returns `MatchedNationalId = true`, echoes a plausible `AccountHolderFromBank` (e.g. the submitted holder name), and a fake `VendorRef` (e.g. `MOCK-SHEBA-{hash}`); for a designated **mismatch** test IBAN it returns `MatchedNationalId = false` (and a different holder name) so the ownership-mismatch path is testable. **No real bank/KYC call, no money moves.** | **add a new row** (🟡) |
|
|
| `IFieldEncryptor` | reuse from **b0** | local symmetric key from config; encrypts `iban`, `account_holder_name`, `initial_medical_notes`, emergency contacts, and computes the deterministic `iban_hash` via `Hash(iban)`. Never logs plaintext. | reuse row |
|
|
| `ICurrentUser` | reuse from **b0** | resolves `UserId` for tenancy scoping. | reuse row |
|
|
| `IDateTimeProvider` | reuse from **b0** | testable `UtcNow` for `verified_at`/audit. | reuse row |
|
|
|
|
The mock lives behind a **DI-registered interface** in Infrastructure (real impl is a drop-in later);
|
|
selection is **config-driven, never an `if (mock)` branch** in a handler. Append the
|
|
`IBankAccountOwnershipVerifier` row 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** — pick a Finnotech/banking-bridge
|
|
استعلام شبا provider, add its client + package + settings, implement `VerifyOwnershipAsync` against the real
|
|
Sheba-owner inquiry, persist the real `ownership_vendor_ref`/`external_response_json`, and what to test).
|
|
|
|
> Do **not** pre-build the verification-pipeline seams (`IShahkarVerifier`, `IIdentityKycProvider`,
|
|
> `ICredentialVerifier`) — those are introduced in [b6](./backend-phase-6.md). This phase owns only
|
|
> `IBankAccountOwnershipVerifier`.
|
|
|
|
## 5. Critical rules you must not get wrong
|
|
|
|
- **`is_verified` is write-guarded — never expose a setter.** Model `nurse_profiles.is_verified` with no
|
|
public setter (private-set + a domain method the verification transaction calls). It is flipped **ONLY**
|
|
inside the b6 verification-confirm transaction once every required `verification_steps.status='passed'`.
|
|
No command, controller, mapping, or update in *this* phase may set it; a profile is created with
|
|
`is_verified = 0` and stays there until b6. A nurse is **not bookable** until verified — onboarding a
|
|
profile must never imply bookability.
|
|
- **Tenancy invariant — a patient belongs to its customer, always.** Resolve `customer_id` from
|
|
`ICurrentUser`, **never** from the request body. Every patient read/write is scoped to the signed-in
|
|
customer; reading or mutating another customer's patient returns `NotFoundResult` (don't leak existence).
|
|
A patient used in a booking must belong to the same `customer_id` (fully enforced in b8, scoping begins
|
|
here). The same ownership scoping applies to `nurse_bank_accounts` (a nurse only ever touches their own).
|
|
- **Encrypt all PII at rest.** `national_id` (already, from b2), `iban`, `account_holder_name`,
|
|
`default_emergency_contact_name`, `default_emergency_contact_phone`, and `initial_medical_notes` route
|
|
through `IFieldEncryptor`. Never store or log plaintext; never project plaintext PII into a non-authorized
|
|
response. On the wire, IBAN is **masked** (last 4) in list responses per the masking rule.
|
|
- **`iban_hash` is UNIQUE — one IBAN can't serve two nurses.** Compute it deterministically via
|
|
`IFieldEncryptor.Hash(iban)` and rely on the `UNIQUE(iban_hash)` index as the authoritative duplicate
|
|
guard. A duplicate add is a **clean** `409`/`FailureResult`, not an unhandled `DbUpdateException`. (The
|
|
encrypted `iban` column itself is non-deterministic and can't be uniquely indexed — that's *why* the hash
|
|
exists.)
|
|
- **Exactly one primary bank account per nurse.** The filtered `UNIQUE(nurse_id) WHERE is_primary = 1`
|
|
index is the backstop; `SetPrimaryBankAccountCommand` clears the old primary and sets the new one in **one
|
|
transaction** so the constraint never trips. Never let two `is_primary = 1` rows exist for a nurse.
|
|
- **First payout is gated on `matched_national_id = true`** — set here by the استعلام شبا inquiry
|
|
(`IBankAccountOwnershipVerifier`), enforced at payout time in [b13](./backend-phase-13.md). Never mark a
|
|
payout-ready state off admin eyeballing; the holder-national-id ↔ verified-nurse-national-id match (money-mule
|
|
prevention) is the gate. `matched_national_id` starts NULL and only the inquiry sets it.
|
|
- **Customer national-ID KYC is DEFERRED** — do not create `national_id_verified_at` on
|
|
`customer_profiles`, do not collect it, and **never gate customer browsing or booking on it.**
|
|
- **Aggregates and `is_verified` are read-only inputs to this phase.** `average_rating`, `total_reviews`,
|
|
`total_completed_bookings` default to 0 and are written only by later phases; reject any attempt to set
|
|
them (or `is_verified`) from a request body.
|
|
- **Reads are projected + paginated; money/PII never leaks.** AsNoTracking + `.Select` to a DTO on every
|
|
read; `ListPatientsQuery`/`ListNurseBankAccountsQuery` paginate; no unbounded `ToListAsync()`. Audit fields
|
|
are stamped by the interceptor, not the handler.
|
|
|
|
## 6. Definition of Done
|
|
|
|
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
|
|
- [ ] The four tables (`nurse_profiles`, `customer_profiles`, `patients`, `nurse_bank_accounts`) exist via
|
|
**one migration**, each with its `IEntityTypeConfiguration<T>`: the 1:1 UNIQUE on `nurse_profiles.user_id`
|
|
and `customer_profiles.user_id`, the `UNIQUE(iban_hash)`, the filtered `UNIQUE(nurse_id) WHERE is_primary=1`,
|
|
the guarded (no-public-setter) `is_verified`, encrypted PII columns, soft-delete filter on
|
|
`nurse_profiles`, and audit wiring. **None** of the CUT columns are present.
|
|
- [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads,
|
|
FluentValidation validators), with the four controllers, each role-scoped and tenancy-enforced.
|
|
- [ ] **`IBankAccountOwnershipVerifier`** introduced (Application interface, Infrastructure mock, DI
|
|
registration via a `ServiceConfiguration/` extension, config-selected). No `if (mock)` in handlers.
|
|
- [ ] Tenancy is provably enforced (a customer cannot read/mutate another customer's patient; a nurse cannot
|
|
touch another nurse's bank account); duplicate-IBAN and duplicate-primary are blocked by the DB
|
|
indexes and surfaced as clean failures.
|
|
- [ ] Handler unit tests (NSubstitute) for: nurse/customer profile upsert; patient CRUD + the
|
|
**cross-customer rejection**; bank-account add → ownership-inquiry sets `matched_national_id`; the
|
|
mismatch-IBAN path; `set_primary` flips the filtered-unique correctly; duplicate-IBAN rejected via
|
|
`iban_hash`. ≥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/Identity/**` profile/patient/bank areas and the
|
|
`IBankAccountOwnershipVerifier` seam are reflected in the **Project map** in `server/CLAUDE.md`.
|
|
- [ ] The contract `dev/contracts/domains/identity-profiles.md` is written and the `swagger.json` snapshot
|
|
republished.
|
|
|
|
## 7. How to test (what a human can verify after this phase)
|
|
|
|
Log in as a nurse and as a customer (reuse b2's OTP flow / a seeded session). Then:
|
|
|
|
1. **Nurse profile** — `POST api/v1/nurse_profiles/upsert` with bio/experience → a `nurse_profiles` row is
|
|
created with `is_verified = 0` and `is_accepting_bookings = 0`; `GET api/v1/nurse_profiles/me` returns it
|
|
with read-only aggregates at 0. Confirm there is **no** endpoint or body field that can set `is_verified`.
|
|
2. **Accepting-bookings toggle** — `POST api/v1/nurse_profiles/set_accepting_bookings` → flips
|
|
`is_accepting_bookings`; `is_verified` is untouched.
|
|
3. **Customer profile** — `POST api/v1/customer_profiles/upsert` with an emergency contact → row created;
|
|
`GET …/me` returns it; verify the stored emergency-contact phone is **encrypted at rest** (inspect the
|
|
column — it is ciphertext, not the plaintext number).
|
|
4. **Patient CRUD** — `POST api/v1/patients/create` (with required `gender`) → patient created under the
|
|
signed-in customer; `GET api/v1/patients/list` shows it; `update`/`archive` work; `initial_medical_notes`
|
|
is ciphertext at rest.
|
|
5. **Tenancy rejection** — as customer **A**, create a patient; as customer **B**, call
|
|
`GET api/v1/patients/get/{A's id}` and `POST api/v1/patients/update/{A's id}` → both return **404/not-found**
|
|
(B can never see or mutate A's patient). This is the load-bearing tenancy test.
|
|
6. **Add bank account + ownership inquiry** — `POST api/v1/nurse_bank_accounts/add` with a normal test IBAN →
|
|
account created; the mock `IBankAccountOwnershipVerifier` runs and sets `matched_national_id = true`,
|
|
`account_holder_from_bank`, and `ownership_vendor_ref`; `GET …/list` shows the IBAN **masked** (last 4).
|
|
7. **Ownership mismatch** — add an account with the designated **mismatch** test IBAN → `matched_national_id =
|
|
false` and the mismatch holder name is recorded (the payout-gating path can later reject it).
|
|
8. **Duplicate IBAN** — add the **same** IBAN again (same nurse or a second nurse) → rejected with a clean
|
|
`409`/failure via the `iban_hash` UNIQUE, **not** an unhandled exception.
|
|
9. **Primary flip** — add a second account and `POST api/v1/nurse_bank_accounts/set_primary/{id2}` → account 2
|
|
becomes primary and account 1 is no longer primary; at no point do two `is_primary = 1` rows exist (the
|
|
filtered unique holds). Attempting to force a second primary without clearing the first is blocked.
|
|
|
|
## 8. Hand off & document (close the phase)
|
|
|
|
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
|
|
`Features/Identity/**` profile/patient/bank areas + the `IBankAccountOwnershipVerifier` seam and where it's
|
|
registered). If you discover/confirm a rule the product docs don't capture (e.g. the masked-IBAN-on-list
|
|
behaviour, or the default-first-account-becomes-primary behaviour), record it in
|
|
[`product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md) —
|
|
don't invent rules.
|
|
- **Contract to write:** **`dev/contracts/domains/identity-profiles.md`** (per
|
|
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the nurse-profile,
|
|
customer-profile, patient, and nurse-bank-account endpoints; the `gender` (`male`/`female`) and
|
|
`blood_type` enums; the profile/patient/bank-account DTO shapes (read-only `is_verified` + aggregates;
|
|
**masked** IBAN; encrypted fields stated as masked vs full); auth/role/rate-limit notes; and the tenancy,
|
|
guarded-`is_verified`, `iban_hash`-uniqueness, single-primary, and `matched_national_id`-gates-first-payout
|
|
side-effects. Republish the `swagger.json` snapshot per
|
|
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f2-b3** consumes.
|
|
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-3.md` (profiles,
|
|
patients, and nurse bank accounts are live; what **f2-b3** can now build — "who is care for"/patient
|
|
add-list-edit, customer profile, nurse profile bootstrap, nurse bank-account settings; which
|
|
endpoints/contracts are live; that addresses/service-areas are **DEFERRED to b4**; that the IBAN-ownership
|
|
rail is mocked behind `IBankAccountOwnershipVerifier`), append to `backend/STATUS.md`, write
|
|
`dev/shared-working-context/reports/backend-phase-3-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: addresses (b4),
|
|
the `is_verified` flip (b6), payout gating on `matched_national_id` (b13), aggregate recompute (b9/b14)),
|
|
and update `dev/shared-working-context/reports/mocks-registry.md` (the `IBankAccountOwnershipVerifier`
|
|
row → 🟡).
|
|
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the guarded
|
|
`is_verified` (no setter, flipped only in b6), the patient tenancy invariant + cross-customer-returns-404
|
|
rule, the `iban_hash` deterministic-hash uniqueness, the filtered single-primary index, and the
|
|
`matched_national_id`-gates-first-payout link to b13 — with a one-line pointer in `MEMORY.md`.
|