add build development phases

This commit is contained in:
hamid
2026-06-28 21:59:59 +03:30
parent 1df3cd9f64
commit 53a40dc51d
52 changed files with 12379 additions and 0 deletions
+341
View File
@@ -0,0 +1,341 @@
# 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`.