backend phase 14 & frontend phase 7

This commit is contained in:
hamid
2026-07-09 15:30:03 +03:30
parent de53f9d8a6
commit 93cc5ecb98
101 changed files with 12930 additions and 39 deletions
@@ -0,0 +1,91 @@
# Backend Phase 14 — Reviews, ratings & patient care records — Report
**Date:** 2026-07-09 · **Track:** backend · **Status:** complete, gate green.
Closes the trust loop (moderated reviews + an honest, recomputed-from-source public rating + low-rating safety
alerts) and the continuity-of-care loop (encrypted, patient-scoped clinical notes under strict access).
## What was built
### Schema (one additive migration `ReviewsAndPatientCareRecords`, `reviews` schema)
- **`Reviews`** — one per completed booking. `UNIQUE(booking_id)` (1:1), `CHECK(rating BETWEEN 1 AND 5)`, guarded
`moderation_status` (+ reason/moderator/time), soft-delete filter. Indexes: unique `booking_id`,
`(nurse_profile_id, moderation_status)`, `moderation_status`. Marked `IAuditable` → the SaveChanges interceptor
writes an append-only `audit_logs` diff on creation and every moderation transition.
- **`ReviewTagsMaster`** — seeded vocabulary (`punctual/professional/clean/kind/communicative`), `UNIQUE(code)`.
- **`ReviewTagLinks`** — N:N join, `UNIQUE(review_id, review_tag_master_id)`.
- **`PatientCareRecords`** — nurse-authored, **patient-scoped** (`patient_id`, not booking), nullable `booking_id`
provenance, `body_encrypted` (ciphertext, no EF converter), `(patient_id, recorded_at)` index, soft-delete.
- **`NurseProfile`** gained `SetReviewAggregates(avg, count)` — the single sanctioned write for the existing
`AverageRating`/`TotalReviews` columns (no new aggregate table).
### Features (CQRS, `Baya.Application/Features/`)
- **Reviews/**: `SubmitReviewCommand`, `ModerateReviewCommand`, `AttachReviewTagsCommand`,
`ListReviewsForNurseQuery`, `GetReviewModerationQueueQuery`, `GetTagAggregatesQuery`; plus the internal
`RecomputeNurseRating` from-source helper and `ReviewCache` (cached public aggregate + eviction).
- **PatientCareRecords/**: `WritePatientCareRecordCommand`, `GetPatientHistoryQuery`.
### Endpoints (5 controllers)
| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `POST /v1/bookings/{bookingId}/review` | SubmitReview | customer (owns booking) |
| `POST /v1/reviews/{reviewId}/tags` | AttachReviewTags | author / moderator |
| `PATCH /v1/reviews/{reviewId}/status` | ModerateReview | admin / moderator |
| `GET /v1/nurses/{nurseProfileId}/reviews` | ListReviewsForNurse | public |
| `GET /v1/nurses/{nurseProfileId}/review_tags` | GetTagAggregates | public |
| `GET /v1/admin/reviews/moderation_queue` | GetReviewModerationQueue | admin |
| `POST /v1/patients/{patientId}/care_records` | WritePatientCareRecord | nurse (confirmed booking) |
| `GET /v1/patients/{patientId}/care_records` | GetPatientHistory | owner / nurse / admin |
### Key design decisions (non-obvious)
- **Recompute is exclude-and-fold, in one transaction.** A fresh LINQ query can't see the tracked, uncommitted
status change, so `RecomputeNurseRating` reads `COUNT`/`SUM(rating)` over the nurse's published reviews
**excluding the transitioning review** (id `0` for a brand-new one), then folds in that review's *new* status in
memory. Correct-from-source and single-commit — no `+delta`/`-delta`, no stale re-query, no second commit.
- **Care records encrypt manually (no EF value converter).** Unlike the b3/b9 converter-backed columns,
`body_encrypted` stores ciphertext directly; the handler `Encrypt`s on write and `Decrypt`s only after the access
check passes — so no query path (list, projection) can ever surface plaintext. `recorded_at` is `DateTime` (UTC),
not `DateTimeOffset`, so the SQLite test provider can `ORDER BY` it.
- **AI verdict → initial disposition.** `SubmitReview` calls `IReviewModerationService.ScreenAsync`; the verdict
maps to the initial status (`Approve`→published, `Reject`→hidden, else pending). The mock defaults clean text to
a human-review **Flag** so the **publish gate holds by default** (reviews land `pending_moderation`);
`Seams:ReviewModeration:AutoApproveClean` opts into auto-publish. Human `ModerateReview` always overrides.
## What is now testable and exactly how (the phase §7 steps)
Run the API against SQL Server; use Swagger/curl.
1. **Submit on a completed booking → accepted.** `POST bookings/{completedId}/review {rating:5}` as the owner →
`200`, `moderationStatus: pending_moderation`. It does **not** appear in `GET nurses/{id}/reviews` (publish gate).
2. **Submit on a cancelled booking → rejected;** a second submit on an already-reviewed booking → conflict (1:1).
3. **Moderate publish recomputes up.** `PATCH reviews/{id}/status {action:"publish"}` → the review appears in the
public list; the aggregate `publishedCount` increments and `averageRating` reflects it.
4. **Moderate hide recomputes down.** Publish a 5★ and a 1★, then `hide` the 1★ → the public list drops it and the
aggregate rises / count decrements (re-derived from source).
5. **Low rating raises an alert.** `POST … {rating:1}` → a `low_rating` `support_alerts` row exists (visible only on
the admin/internal path — never in a user response; its id shows on the moderation queue row).
6. **Write + read a care record.** As a nurse with a confirmed booking, `POST patients/{P}/care_records``200`;
the stored column is ciphertext. As the owning customer or that nurse, `GET …` → decrypted note, newest first.
7. **Unauthorized nurse denied.** A nurse with no confirmed booking for P → `403` on both write and read.
**Automated coverage:** 13 Foundation handler tests (`Baya.Test.Foundation/Reviews/`) cover steps 17 incl. the
recompute-from-source math (hide lowers count **and** average) and ciphertext-at-rest; 4 API tests
(`Baya.Test.Api/ReviewsApiTests.cs`) cover the HTTP pipeline (public reads anonymous, admin/patient reads 401
without a token). Full suite: **346 pass**, build clean (0 new warnings).
## What is mocked / waiting on a real service
- **`IReviewModerationService`** (introduced here) — `MockReviewModerationService` (keyword filter / pass-through),
config `Seams:ReviewModeration:{AutoApproveClean,BannedWords}`. Make it real → a text classifier / LLM endpoint,
registration-only swap (see reports/mocks-registry.md, row → 🟡). `ModerateReviewCommand` keeps decision authority.
- Reused seams: `IFieldEncryptor` (clinical notes), `ISearchIndexMaintainer` (aggregate refresh),
`ISupportAlertService` (low-rating alert), `INotificationDispatcher` (review-outcome notice), `IPlatformConfig`
(`min_rating_for_support_alert`), `ICacheService` (aggregate cache).
## Contracts produced
- `dev/contracts/domains/reviews-records.md` (8 endpoints, enums, DTOs, care-record access matrix).
- `dev/contracts/openapi/swagger.v1.json` refreshed (all 8 paths present).
## Follow-ups for later phases (b15)
- Ticket system, partner centers, and the admin **support-alert worklist console** (this phase only *raises*
`low_rating` alerts).
- `SuspendNurse` / `ResolveSupportAlert` / `FlagConcern` admin actions.
- Deferred by design: two-way double-blind reviews with timed reveal; a first-class `incidents` entity + ML fraud
scoring; optional structured `vitals_encrypted` on care records.
@@ -0,0 +1,118 @@
# Frontend Phase 7 — Booking request flow (C4 + C5 + nurse inbox) — report
**Date:** 2026-07-09 · **Track:** frontend · **Depends on:** f6 (search/nurse profile → the CTA + the
nurse's variants), f3 (addresses + map preview), f2 (patients), f0 (money/date utils, services pattern,
stepper/status composites) · **Consumes:** b8 `booking-requests.md` · **Unlocks:** f8 (booking detail →
the `converted` handoff) + f9 (checkout → the payment CTA).
## What was built
The money-free **request phase** of the engagement lifecycle — a customer turns a nurse profile into a
sent request and both sides close the loop, with server-frozen deadlines and two-stage clinical disclosure
made visible.
### `services/bookingRequests/` (the domain, copies the f0/auth shape)
- **`types.ts`** — `RequiredCaregiverGender`, the full `BookingRequestStatus` union + `TERMINAL_*` set +
`isTerminalBookingRequestStatus`, `BookingRequestDto` (customer/admin full-address vs nurse masked view),
`BookingRequestListItem`, `CreateBookingRequestPayload`, `RejectBookingRequestPayload`, the paginated
list params, `BookingRequestDisplayContext` (mock-only display aid), and the `BookingRequestsApi` seam.
Derived from the b8 contract; `variantPrice` is documented **client-augmented** (REQ-013).
- **`keys.ts`** — `bookingRequestKeys.list(role,status,page)` / `.nurseInbox(...)` / `.detail(id)`.
- **`constants.ts`** — `USE_BOOKING_REQUESTS_MOCK` (true, primary), poll/stale/gc times, page size, the
notes/reason limits, and the mock-only deadline windows.
- **`apis/`** — `mockApi.ts` (**primary**; a shared in-memory state machine, see below), `clientApi.ts`
(real b8 1:1 mapping — action-style snake_case routes, camelCase bodies, ids from the route), `index.ts`
(seam selection by the flag).
- **`hooks/`** — `useCreateBookingRequest` (mutation → seeds the detail cache + invalidates the customer
list), `useBookingRequest(id, role)` (query; **polls while non-terminal, stops at terminal/`converted`**
via a `refetchInterval` guard; `role` drives the mock's nurse-view masking), `useNurseRequestInbox`
(query; light polling), `useCustomerRequests` (query; for f8 reuse + create/cancel invalidation),
`useAccept`/`useReject`/`useCancel` (mutations → invalidate inbox list + detail). Barrel re-exports hooks.
### Screens (all RTL/i18n/dark-mode)
- **C4** `/bookings/request` (customer) — the request form. Patient dropdown (f2; empty → link to
`/patients`), service-variant dropdown (the chosen nurse's `services` from the f6 profile) + a
`PriceDisplay` of the picked rate, address dropdown (f3) + a read-only **map-pin preview** of the saved
address's coordinates, native date + time-window fields, a **first-class 3-way caregiver-gender toggle**
(خانم/آقا/فرقی ندارد) with a why-line, and a stage-1 notes field with a counter + honesty copy. Defaults
are **derived during render** (no setState-in-effect): the variant to the carried/first, the address to
the primary/first. The CTA is gated on all required fields + a client-side same-gender-mismatch block;
domain 400 codes map to field/form errors. On success → C5.
- **C5** `/bookings/request/[id]` (customer) — the awaiting screen. `BookingRequestSummaryCard` + the
3-step `StepperHeader` tracker + a status-driven body: **pending** shows a `CountdownTimer` to
`nurseResponseDeadlineAt`; **accepted** swaps the tracker, shows a ✓ badge + a terracotta 30-min
`CountdownTimer` to `paymentDeadlineAt` + the **ادامه پرداخت** CTA → `/bookings/checkout`; **rejected /
expired / payment-expired / cancelled** are terminal cards with a re-request CTA into search;
**converted** routes to booking (f8). Cancel-with-confirm while pending/accepted. Polls until terminal.
- **Nurse inbox** `/nurse/requests` — pending requests, each a card with patient name, Shamsi time, the
**required-gender chip**, a `customerNotes` preview, and a per-request `CountdownTimer`. Empty state +
light polling. New nurse-shell nav item.
- **Nurse detail** `/nurse/requests/[id]` — the summary + **only `customerNotes`** (masked coarse
city/district, a disclosure note) + accept / reject-with-reason (a dialog capturing the reason). A stale
`409` surfaces a warning + refetch. Both actions invalidate the inbox + detail.
- **`/bookings/checkout`** — an f9 DEFERRED stub (PlaceholderScreen) so the accept CTA doesn't dead-end.
### Shared composites (tested)
- **`CountdownTimer`** — a pure countdown to a **server-frozen** UTC instant; owns its own 1-second tick so
only it re-renders (never the page), stops at zero and shows an elapsed label + fires `onElapsed` once,
renders locale digits forced LTR. 4 tests (MM:SS / HH:MM:SS / tick / elapsed+onElapsed via fake timers).
- **`BookingRequestSummaryCard`** — nurse identity + rating, patient, priced service (`PriceDisplay` when a
price is present), address label, Shamsi date/time. Reused by C5 + nurse detail; f8 reuses it too. 4 tests.
### Other
- Routes: `BOOKING_REQUEST` (now the C4 form), `BOOKING_REQUEST_STATUS`, `CHECKOUT`, `NURSE_REQUESTS`.
Icons: `requests`, `payment`. i18n: `booking` namespace fully fleshed + `nav.requests`, both locales in
sync. The prior `/bookings/request` f6-handoff **stub was replaced** by the real C4 form.
## Now testable, and exactly how (§7 of the phase)
Run `npm run dev` (mock is primary — no backend needed). Within one browser tab (the mock store is a shared
module singleton):
1. **Submit:** from a nurse profile (C3) tap **درخواست رزرو** → C4 pick patient/variant/address/date/time +
a gender → **ارسال درخواست** → land on **C5** (tracker step 2 active, response countdown ticking).
2. **Nurse sees it:** open `/nurse/requests` → the new request appears with the gender chip + notes +
countdown; open the detail → **only the notes** (no address/clinical fields).
3. **Accept:** tap accept → it leaves the pending inbox (invalidated); navigate back to **C5** → step 2
done, step 3 active, **✓ پرستار تایید کرد**, the **30-min payment countdown**, and **ادامه پرداخت →**
(routes to the checkout stub with `request_id`).
4. **Reject:** on another request, reject with a reason → C5 shows the **rejected** terminal card + reason.
5. **Expiry:** the mock shortens the response window (see below) — once it lapses, a C5 poll (or reopening)
shows **expired_no_response**; after accept, the 30-min window lapsing shows **payment_deadline_expired**.
6. **Quality:** flip fa↔en (dir + strings), dark mode holds; Devtools shows the inbox/detail invalidating on
accept/reject and the C5 poll stopping at a terminal status.
## What is mocked (and how it swaps to real)
`services/bookingRequests` is **mock-primary** (`USE_BOOKING_REQUESTS_MOCK = true`). b8 is live server-side,
but this is a **client-side** mock for two reasons: (a) every *input* id — the nurse (f6 search), the
patient (f2), the address (f3) — comes from a **mock-primary** upstream domain, so a real
`booking_requests/create` would reference ids that don't exist in a real DB; (b) the contract DTO omits the
variant price the summary renders (REQ-013). The mock is a **shared in-memory state machine**: one
module-level store drives create → the nurse inbox → accept/reject/cancel → the customer C5 poll, with a
**lazy expiry sweep** on every read (the client stand-in for the server's background job), the nurse-view
**address masking**, and the forward-only status/deadline rules. Mock-only deadline windows: the payment
window is contract-accurate (30 min); the **response** window is shortened to 30 min (a stand-in for the
server's 24h) so a session can observe the expiry path. The real `bookingRequestsClientApi` maps the b8
contract 1:1 (the `context`/`role` args are mock-only and ignored). **Swapping to real is a one-line flag
flip** once the upstream domains are live and REQ-013 lands — no hook/component change. (Client-side mock;
recorded here, not in `mocks-registry.md`, which tracks backend DI seams.)
## Contracts consumed / requests filed
- **Consumed (not edited):** `dev/contracts/domains/booking-requests.md` (b8) — types derive from it. Note:
the actual routes are `booking_requests/{create,accept/{id},reject/{id},cancel/{id},list,get/{id}}` (the
phase file's illustrative `create_booking_request`-style names were superseded by the contract), and the
wire uses `requestedDate` + `requestedTimeStart/End` (not a single `scheduled_start_at`).
- **Filed:** `frontend/requests/for-backend.md`**REQ-013** (`variantPrice` + `nurseAvatarUrl` on
`BookingRequestDto`) and **REQ-014** (`variantLabel` + `patientAge` on `BookingRequestListItemDto`).
## Follow-ups for later phases
- **f8 (booking detail):** the C5 `converted` state routes to `/bookings` today — wire it to the real
booking-detail route when f8 lands. f8 should reuse `BookingRequestSummaryCard`.
- **f9 (checkout):** the C5 accept CTA → `/bookings/checkout?request_id=…` (a stub). f9 builds the C6
summary + escrow + card/BNPL and consumes the accepted request id.
- **Swap to real b8:** flip `USE_BOOKING_REQUESTS_MOCK=false` once search/patients/addresses are live and
REQ-013/014 land; verify the nurse-view masking + deadline freezing against the server.
## Gate
`npm run check` green · `npm run test:ci` green (173 tests, +8) · `npm run build` green with
`NEXT_PUBLIC_API_URL` set (all five new routes compile + prerender). Without the env var the build fails on
the **pre-existing** "Missing .env variable!" from `@/config` — it hits the existing `/fa/search` route
identically, so it is environmental, not an f7 defect.
@@ -30,7 +30,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| `IBankAccountOwnershipVerifier` | backend-phase-3 | استعلام شبا IBAN-owner ↔ national-id inquiry — `MockBankAccountOwnershipVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic fake: every IBAN matches (`matched_national_id=true`, echoes a holder name + `MOCK-SHEBA-{sha}` vendor ref) except the configured mismatch IBAN which returns `false`; registered singleton in `AddCrossCuttingSeams`. No real bank/KYC call, no money moves | `Seams:BankOwnership:MismatchIban` (default `IR000000000000000000000000`), `Seams:BankOwnership:MatchedHolderName`, `Seams:BankOwnership:MismatchHolderName` | 1) pick a Finnotech / banking-bridge استعلام شبا provider, add its client package to `Directory.Packages.props`; 2) add `Seams:BankOwnership:{ApiKey,BaseUrl}` options; 3) implement `VerifyOwnershipAsync(iban, nurseNationalId)` against the real Sheba-owner inquiry, mapping to `OwnershipInquiryResult`; 4) persist the real `ownership_vendor_ref` (+ raw response if a column is added); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match/mismatch + that the b13 first-payout gate honours `matched_national_id=true` | 🟡 |
| `IGeocoder` | backend-phase-4 | Address→lat/lng — `MockGeocoder` (`Baya.Infrastructure.CrossCutting/Seams/`) returns deterministic `decimal` coordinates jittered (FNV-1a, ~±5 km) around the known city centroid (unknown city → Iran centroid) plus `formatted_address` + `confidence`; **no network call**. A global switch or a per-address marker forces the null-coordinate ("no map pin") path; registered singleton in `AddCrossCuttingSeams` | `Seams:Geocoding:ReturnNullCoordinates` (default `false`), `Seams:Geocoding:LowConfidenceMarker` (default `NO_GEO`), `Seams:Geocoding:ResolvedConfidence` (default `0.9`) | 1) pick Neshan (or Google) geocoding, add its client package to `Directory.Packages.props`; 2) add `Seams:Geocoding:{ApiKey,BaseUrl}` options; 3) implement `IGeocoder.GeocodeAsync(addressText, cityName, districtName?)` against it, mapping to `(lat, lng, formatted_address, confidence)` with `decimal` coords; 4) add rate-limit/retry; 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test a known Tehran address resolves within expected bounds | 🟡 |
| `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoice — leaves ref pending | _tbd_ | Real مودیان submission → 22-digit ref | 🔴 |
| `IReviewModerationService` | backend-phase-14 | AI moderation — keyword/pass-through | _tbd_ | Real classifier/LLM endpoint | 🔴 |
| `IReviewModerationService` | backend-phase-14 | AI review pre-screen — `MockReviewModerationService` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `ScreenAsync(reviewText)` returns a `ModerationVerdict(Decision, Reason)` — a banned-word substring hit → `Reject` (`banned_word:{w}`); otherwise clean text → a human-review `Flag` by default (so the publish gate holds), or `Approve` when `AutoApproveClean` is set. The `SubmitReview` handler maps the verdict to the initial status (`Approve`→published, `Reject`→hidden, else pending) — **decision authority stays with `ModerateReviewCommand` (human override)**. Registered singleton in `AddCrossCuttingSeams` | `Seams:ReviewModeration:AutoApproveClean` (default `false`), `Seams:ReviewModeration:BannedWords` (default `scam,fraud,کلاهبردار`) | 1) pick a text classifier / LLM moderation endpoint, add its client package to `Directory.Packages.props`; 2) add `Seams:ReviewModeration:{ApiKey,BaseUrl}` options; 3) implement `ScreenAsync(reviewText)` → map the provider's toxicity/spam scores to `Approve`/`Flag`/`Reject` + a reason; 4) swap the registration in `AddCrossCuttingSeams` (config-selected) — `SubmitReviewCommand`/`ModerateReviewCommand` unchanged, and the human moderation path always overrides; 5) test clean/flagged/rejected dispositions + that the publish gate still holds for a `Flag` | 🟡 |
| `IFieldEncryptor` | backend-phase-0 | PII encryption — AES-256-CBC + HMAC hash from a local symmetric key (`SymmetricFieldEncryptor`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:FieldEncryption:Key`, `Seams:FieldEncryption:HashKey` | KMS / column encryption / Key Vault / HSM | 🟡 |
| `INotificationDispatcher` | backend-phase-0/**1** | Notification channels — **in-app write is now real** (`InAppNotificationDispatcher`, `Persistence/Services/Notifications/`, writes an `ops.Notifications` row); b0 log stub removed. SMS/push channels still deferred (no-op) behind the same seam | _none_ | Add SMS (`ISmsSender`) / push (FCM) channels; polling → Redis pub/sub or SignalR later | 🟡 |
| `ILicenseVerificationService` | backend-phase-15 | eNamad / MoH establishment-permit — manual approve | _tbd_ | Real registry/API | 🔴 |