Files
baya-monorepo/dev/phases/backend/backend-phase-3.md
T
2026-06-28 21:59:59 +03:30

31 KiB

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 (users/auth/OTP/sessions/roles), b0 (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. 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, rolesb2 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.
  • IFieldEncryptorb0 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 interceptorb0. Handlers never set CreatedById/CreatedAt/ModifiedById/ModifiedAt; the interceptor stamps them from ICurrentUser. Use ICurrentUser.UserId for tenancy resolution.
  • The REST surface & CQRS pipelineb0: 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 accessorb1. 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 and ../_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.mdthe 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.mdthe 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 — 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 (envelope, snake_case routes, pagination, auth) and ../../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 hereaverage_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 ICurrentUsercustomer_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. 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 §(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. 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 (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. 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. 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, 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 profilePOST 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 togglePOST api/v1/nurse_profiles/set_accepting_bookings → flips is_accepting_bookings; is_verified is untouched.
  3. Customer profilePOST 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 CRUDPOST 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 inquiryPOST 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 (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 — don't invent rules.
  • Contract to write: dev/contracts/domains/identity-profiles.md (per ../../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. 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.