7.1 KiB
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/money-and-types.md. Machine schema:../openapi/swagger.v1.json.
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(dateYYYY-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 }. ReturnsNurseProfileDto. Never acceptsisVerifiedor the aggregates.400ifyearsOfExperience∉ [0,80];403non-nurse.POST api/v1/nurse_profiles/set_accepting_bookings— body{ accepting: bool }. Empty200on success;404if no profile yet. Never touchesisVerified.GET api/v1/nurse_profiles/me— returnsNurseProfileDto;404if none.
Customer profile — role customer
POST api/v1/customer_profiles/upsert— body{ defaultEmergencyContactName, defaultEmergencyContactPhone }(phone stored encrypted). ReturnsCustomerProfileDto.400invalid phone / empty name;403non-customer.GET api/v1/customer_profiles/me— returnsCustomerProfileDto;404if none.
Patients — role customer (tenancy-scoped to the caller)
POST api/v1/patients/create— body{ displayName, firstName, lastName, birthDate, gender, bloodType, initialMedicalNotes }.customerIdis derived from the caller (a thin customer profile is auto-provisioned on first patient) — never taken from the body. ReturnsPatientDto.400missing/invalidgenderor futurebirthDate.GET api/v1/patients/list?page=&pageSize=— paginated (page1-based,pageSize≤100, default 50). ReturnsPagedResult<PatientDto>(items,total,page,pageSize) of the caller's own patients only.GET api/v1/patients/get/{id}— returnsPatientDto;404if not owned (existence not leaked).POST api/v1/patients/update/{id}— body as create (id from the route). ReturnsPatientDto;404if not owned.POST api/v1/patients/archive/{id}— soft-archive (isActive=false, not a delete). Empty200;404if not owned.
Nurse bank accounts — role nurse (tenancy-scoped)
POST api/v1/nurse_bank_accounts/add— rate-limited. Body{ bankName, accountHolderName, iban }(IBANIR+24 digits; stored encrypted). Runs the استعلام شبا ownership inquiry and returnsNurseBankAccountDtowithmatchedNationalIdset. Becomes primary if it is the nurse's first account.400invalid IBAN, duplicate IBAN (viaiban_hashuniqueness — 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). Empty200;404if not owned.GET api/v1/nurse_bank_accounts/list— returnsNurseBankAccountDto[]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 updatedNurseBankAccountDto;404if 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.
matchedNationalIdgates the first payout (b13) — set here by the (mocked)IBankAccountOwnershipVerifier, not by admin eyeballing;nulluntil 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).
Refinement phase 3 additions (REQ-005/006/007)
PatientDto+ create/update gainrelation(parent|spouse|child|self, nullable) andconditions(string[]of stable codes; empty, never null). Stored as a nullable code + a JSON array column.NurseProfileDtoandCustomerProfileDtogainavatarUrl(nullable).CustomerProfileDtoalso gainspreferredLanguage(nullable); the customerupsertbody now acceptsfirstName/lastName(persisted on the baseusersrow) andpreferredLanguage.- Avatar upload (multipart):
POST api/v1/nurse_profiles/avatarandPOST api/v1/customer_profiles/avatar—multipart/form-datafieldfile(JPEG/PNG/WebP, ≤ 5 MB), stored viaIObjectStorage, returns{ url }and persists it on the profile.