Files
baya-monorepo/dev/shared-working-context/reports/backend-phase-3-report.md
T
hamid 39a979b1a7 @
backend phase 3: identity profiles, patients & nurse bank accounts

Add the role-attached identity layer on top of the b2 auth spine: nurse
seller profiles (guarded is_verified, read-only aggregates), thin customer
payer profiles, first-class patients (tenancy-scoped), and nurse payout bank
accounts hardened with an iban_hash uniqueness guard and an automated استعلام
شبا IBAN-ownership inquiry.

- Four usr tables via one migration (1:1 uniques, UNIQUE(iban_hash), filtered
  UNIQUE(nurse_id) WHERE is_primary=1, guarded is_verified, encrypted PII,
  soft-delete on nurse_profiles)
- 15 CQRS slices + 4 role-scoped controllers; reads projected + paginated,
  IBAN masked (last-4); ownership-inquiry endpoints rate-limited
- New IBankAccountOwnershipVerifier seam (mock deterministic شبا match) +
  per-domain repositories on IUnitOfWork + encrypted-PII value converters
- Activate FluentValidation repo-wide (validators were never registered)
- Handler unit tests + WebApplicationFactory integration tests (76 pass);
  contract identity-profiles.md + swagger snapshot; docs, handoff & report

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-07-02 12:03:15 +03:30

5.2 KiB

Backend phase 3 report — Identity: profiles, patients & nurse bank accounts

What was built

  • Four domain entities (Baya.Domain/Entities/Identity/): NurseProfile, CustomerProfile, Patient, NurseBankAccount. NurseProfile.is_verified is write-guarded (private setter + MarkVerified()/MarkUnverified() — only b6 calls it); is_accepting_bookings toggled via a domain method; the search aggregates are read-only.
  • One EF migration IdentityProfilesPatientsBankAccounts (schema usr): 1:1 uniques on user_id, UNIQUE(iban_hash), filtered UNIQUE(nurse_id) WHERE is_primary=1, soft-delete on NurseProfiles, encrypted PII columns, audit fields. No CUT columns.
  • 15 CQRS slices under Features/Identity/{Commands|Queries}/ + 4 sealed : BaseController controllers (NurseProfilesController, CustomerProfilesController, PatientsController, NurseBankAccountsController). Reads project to DTOs; lists paginate; the ownership-inquiry endpoints are rate-limited (sensitive policy).
  • New seam IBankAccountOwnershipVerifier (Application Contracts/Common) + mock MockBankAccountOwnershipVerifier (CrossCutting), registered in AddCrossCuttingSeams, config-selected.
  • Persistence: four per-domain repositories on IUnitOfWork (NurseProfileRepository, CustomerProfileRepository, PatientRepository, NurseBankAccountRepository); encrypted-PII value converters for the new columns wired in ApplicationDbContext.OnModelCreating; an atomic SetPrimaryAsync (clear-then-set in one transaction) so the single-primary index never trips.
  • Infra fix: AddApplicationServices now registers every AbstractValidator<T> as IValidator<T> so the pre-existing ValidateCommandBehavior and ModelStateValidationAttribute filter actually validate (they had no validators registered before this phase — validation was silently inert).

What is now testable, and exactly how (mirrors the phase §7)

Log in as a nurse and a customer (b2 OTP flow), refreshing the token after select_role so the role claim is present. Then:

  1. Nurse profilePOST api/v1/nurse_profiles/upsert → row created is_verified=0, is_accepting_bookings=0; GET …/me shows aggregates at 0. No path sets is_verified.
  2. Accepting-bookingsPOST …/set_accepting_bookings flips it; verified untouched.
  3. Customer profilePOST api/v1/customer_profiles/upsert with emergency contact → GET …/me round-trips it through the encrypted column.
  4. Patient CRUDcreate (gender required) / list / get/{id} / update/{id} / archive/{id}.
  5. Tenancy — customer B calling get/update on customer A's patient id → 404.
  6. Bank account + inquiryPOST api/v1/nurse_bank_accounts/add (normal IBAN) → matched_national_id=true, vendor ref recorded; list shows the IBAN masked.
  7. Mismatch — add the mismatch IBAN → matched_national_id=false.
  8. Duplicate IBAN — re-add the same IBAN → clean 400 via iban_hash uniqueness.
  9. Primary flip — add a 2nd account, set_primary/{id2} → account 2 primary, account 1 not; never two primaries.

Automated coverage: 15 handler unit tests (NSubstitute) covering profile upsert, role forbidden, patient CRUD + cross-customer 404, bank add match/mismatch/duplicate + set-primary flip/not-owned; 13 WebApplicationFactory integration tests (one+ per controller: happy path, 401, validation 400, tenancy 404, mask, duplicate, primary flip, mismatch). dotnet build clean (0 new code warnings); dotnet test green (75 pass).

What is mocked / waiting on a real service

  • IBankAccountOwnershipVerifier (🟡) — deterministic fake استعلام شبا. Make-it-real steps in reports/mocks-registry.md. Reused seams: IFieldEncryptor, ICurrentUser, IDateTimeProvider.

Contracts produced / consumed

  • Produced: dev/contracts/domains/identity-profiles.md; dev/contracts/openapi/swagger.v1.json refreshed (adds all nurse-profile / customer-profile / patient / bank-account paths).
  • Consumed: b2 auth (login/roles), b0 seams (IFieldEncryptor/ICurrentUser/IDateTimeProvider), b1 IPlatformConfig (available; not needed this phase).

Follow-ups for later phases

  • b4: customer_addresses + nurse_service_areas (need geography + geocoder) — deferred here.
  • b6: the is_verified flip (verification-confirm transaction); Shahkar/KYC populate national_id; the bank_account_verification step couples to NurseBankAccounts.
  • b13: first-payout gate on matched_national_id = true.
  • b9/b14: recompute average_rating/total_reviews/total_completed_bookings (read-only here).
  • Chain-wide: validators are now active — new phases must ensure route-supplied ids aren't validated in the body command, and can rely on FluentValidation for input rejection.

Decisions taken (flagged for confirmation)

  • A thin customer_profiles row is auto-provisioned on a customer's first patient (so patient registration needs no separate profile step). Recorded in product/data-model/01-identity-and-access.md.
  • IBAN is returned masked (last-4) on every read; first account added is primary by default.