@
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> @
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
# Contract — Identity profiles, patients & nurse bank accounts (backend phase b3)
|
||||
|
||||
> Role-attached identity data on top of the b2 auth spine: the nurse seller profile, the customer payer
|
||||
> profile, the customer's patients, and the nurse's payout bank accounts. Assumes
|
||||
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
|
||||
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
|
||||
> [`../openapi/swagger.v1.json`](../openapi/README.md).
|
||||
|
||||
**Status:** live as of backend-phase-b3 · **Frontend consumer:** frontend-phase-f2-b3
|
||||
|
||||
All endpoints require a **Bearer access token** (`[Authorize]`); unauthenticated calls return `401`.
|
||||
Role scoping is enforced in the handler and returns `403` when the caller lacks the required role — and
|
||||
**role claims are baked into the access token at mint time**, so a client must refresh (or re-login)
|
||||
after `me/select_role` before these endpoints see the new role. Request bodies are camelCase JSON; URL
|
||||
segments are snake_case; responses use the standard `OperationResult`→`ApiResult` envelope (payload in
|
||||
`data`).
|
||||
|
||||
## Enums used
|
||||
- `gender`: `male` | `female` — load-bearing for same-gender caregiver matching; required on a patient.
|
||||
- `blood_type`: free-form short string (e.g. `O+`, `AB-`), nullable — not a fixed enum at MVP.
|
||||
|
||||
## Shared shapes
|
||||
- `NurseProfileDto`: `id` (int64), `bio` (string), `yearsOfExperience` (int), `educationLevel` (string),
|
||||
`educationField` (string), `specializationsJson` (string — raw JSON array), `isVerified` (bool,
|
||||
**read-only** — always false until b6 verification), `isAcceptingBookings` (bool),
|
||||
`averageRating` (decimal), `totalReviews` (int), `totalCompletedBookings` (int) — the last three are
|
||||
**read-only aggregates**, 0 until reviews/bookings phases.
|
||||
- `CustomerProfileDto`: `id` (int64), `defaultEmergencyContactName` (string), `defaultEmergencyContactPhone`
|
||||
(string) — decrypted and returned **in full** to the owning customer (self).
|
||||
- `PatientDto`: `id` (int64), `displayName`, `firstName`, `lastName` (strings), `birthDate` (date
|
||||
`YYYY-MM-DD`), `gender` (`male`/`female`), `bloodType` (string, nullable), `initialMedicalNotes`
|
||||
(string — decrypted, owner-only), `isActive` (bool).
|
||||
- `NurseBankAccountDto`: `id` (int64), `bankName` (string), `ibanMasked` (string — **last 4 only**, e.g.
|
||||
`••••3456`; the full IBAN is never returned), `isPrimary` (bool), `isVerified` (bool),
|
||||
`matchedNationalId` (bool **nullable** — null until the ownership inquiry runs).
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Nurse profile — role `nurse`
|
||||
- `POST api/v1/nurse_profiles/upsert` — create/update own profile. Body: `{ bio, yearsOfExperience,
|
||||
educationLevel, educationField, specializationsJson }`. Returns `NurseProfileDto`. **Never accepts
|
||||
`isVerified` or the aggregates.** `400` if `yearsOfExperience` ∉ [0,80]; `403` non-nurse.
|
||||
- `POST api/v1/nurse_profiles/set_accepting_bookings` — body `{ accepting: bool }`. Empty `200` on
|
||||
success; `404` if no profile yet. Never touches `isVerified`.
|
||||
- `GET api/v1/nurse_profiles/me` — returns `NurseProfileDto`; `404` if none.
|
||||
|
||||
### Customer profile — role `customer`
|
||||
- `POST api/v1/customer_profiles/upsert` — body `{ defaultEmergencyContactName, defaultEmergencyContactPhone }`
|
||||
(phone stored **encrypted**). Returns `CustomerProfileDto`. `400` invalid phone / empty name; `403` non-customer.
|
||||
- `GET api/v1/customer_profiles/me` — returns `CustomerProfileDto`; `404` if none.
|
||||
|
||||
### Patients — role `customer` (tenancy-scoped to the caller)
|
||||
- `POST api/v1/patients/create` — body `{ displayName, firstName, lastName, birthDate, gender, bloodType,
|
||||
initialMedicalNotes }`. `customerId` is derived from the caller (a thin customer profile is
|
||||
auto-provisioned on first patient) — **never taken from the body**. Returns `PatientDto`. `400` missing/invalid
|
||||
`gender` or future `birthDate`.
|
||||
- `GET api/v1/patients/list?page=&pageSize=` — paginated (`page` 1-based, `pageSize` ≤100, default 50).
|
||||
Returns `PagedResult<PatientDto>` (`items`, `total`, `page`, `pageSize`) of the caller's **own** patients only.
|
||||
- `GET api/v1/patients/get/{id}` — returns `PatientDto`; `404` if not owned (existence not leaked).
|
||||
- `POST api/v1/patients/update/{id}` — body as create (id from the route). Returns `PatientDto`; `404` if not owned.
|
||||
- `POST api/v1/patients/archive/{id}` — soft-archive (`isActive=false`, not a delete). Empty `200`; `404` if not owned.
|
||||
|
||||
### Nurse bank accounts — role `nurse` (tenancy-scoped)
|
||||
- `POST api/v1/nurse_bank_accounts/add` — **rate-limited**. Body `{ bankName, accountHolderName, iban }`
|
||||
(IBAN `IR`+24 digits; stored encrypted). Runs the استعلام شبا ownership inquiry and returns
|
||||
`NurseBankAccountDto` with `matchedNationalId` set. Becomes primary if it is the nurse's first account.
|
||||
`400` invalid IBAN, **duplicate IBAN** (via `iban_hash` uniqueness — a clean failure, not a 500), or no nurse profile.
|
||||
- `POST api/v1/nurse_bank_accounts/set_primary/{id}` — makes the account primary and clears the prior
|
||||
primary atomically (the filtered single-primary index never trips). Empty `200`; `404` if not owned.
|
||||
- `GET api/v1/nurse_bank_accounts/list` — returns `NurseBankAccountDto[]` with **masked** IBANs.
|
||||
- `POST api/v1/nurse_bank_accounts/verify_ownership/{id}` — **rate-limited**. Re-runs the ownership
|
||||
inquiry (idempotent: same input → same vendor ref). Returns the updated `NurseBankAccountDto`; `404` if not owned.
|
||||
|
||||
## Side effects & rules the API enforces
|
||||
- **Guarded `isVerified`** — there is no field or endpoint to set it; a nurse profile is created
|
||||
unverified and stays so until the b6 verification-confirm transaction.
|
||||
- **Tenancy** — a customer only ever sees/mutates their own patients; a nurse only their own bank
|
||||
accounts. Cross-tenant access returns `404` (never leaks existence).
|
||||
- **IBAN masking** — the full IBAN is never returned; lists/DTOs carry last-4 only. The full value is
|
||||
encrypted at rest.
|
||||
- **`matchedNationalId` gates the first payout (b13)** — set here by the (mocked)
|
||||
`IBankAccountOwnershipVerifier`, not by admin eyeballing; `null` until the inquiry has run.
|
||||
- **Deferred:** saved service addresses & nurse coverage areas (b4); customer national-ID KYC (not
|
||||
collected, never gates browsing/booking).
|
||||
|
||||
## Changelog
|
||||
- b3 — initial contract (nurse/customer profiles, patients, nurse bank accounts + ownership inquiry).
|
||||
Reference in New Issue
Block a user