backend phase 14 & frontend phase 7
This commit is contained in:
@@ -12,6 +12,24 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
|
||||
- **Notes for frontend:** <anything load-bearing>
|
||||
-->
|
||||
|
||||
## backend-phase-14 — Reviews, ratings & patient care records — 2026-07-09
|
||||
- **Shipped:** new `reviews` schema, 4 tables — `Reviews` (`UNIQUE(booking_id)`, `CHECK(rating 1–5)`, guarded
|
||||
`moderation_status`, `IAuditable`), `ReviewTagsMaster` (seeded 5-tag vocab, `UNIQUE(code)`), `ReviewTagLinks`
|
||||
(`UNIQUE(review_id, review_tag_master_id)`), `PatientCareRecords` (encrypted, patient-scoped, `(patient_id,
|
||||
recorded_at)` index). One migration (`ReviewsAndPatientCareRecords`). CQRS: SubmitReview / ModerateReview /
|
||||
AttachReviewTags / ListReviewsForNurse / GetReviewModerationQueue / GetTagAggregates / WritePatientCareRecord /
|
||||
GetPatientHistory + the `RecomputeNurseRating` from-source helper. 8 endpoints across 5 controllers. Added
|
||||
`NurseProfile.SetReviewAggregates` (guarded aggregate write) + `ICustomerProfileRepository.GetUserIdByProfileIdAsync`.
|
||||
- **Contracts:** `dev/contracts/domains/reviews-records.md` + openapi snapshot refreshed (yes).
|
||||
- **Mocked:** `IReviewModerationService` (AI pre-screen — keyword/pass-through) → 🟡 (see reports/mocks-registry.md).
|
||||
- **Gate:** build clean (0 new warnings) / tests green (346 total: 4 identity + 236 foundation + 106 API,
|
||||
incl. 13 new review/care-record handler tests + 4 new API tests).
|
||||
- **Handoff:** backend/handoff/after-backend-phase-14.md
|
||||
- **Notes for frontend:** publish gate — only `published` reviews are ever public/counted; the nurse aggregate
|
||||
recomputes from source on every transition. Care records are patient-scoped + encrypted + strict access
|
||||
(owner/assigned-nurse/admin). Money-free domain. Support alerts stay internal (only `lowRatingAlertId` on the
|
||||
admin queue).
|
||||
|
||||
## backend-phase-13 — Weekly nurse payouts (mocked PAYA/SATNA) — 2026-07-09
|
||||
- **Shipped:** new `payouts` schema, 3 tables — `NursePayoutBatches` (holiday-shifted period/processing dates),
|
||||
`NursePayouts` (net-split CHECK, encrypted `iban_snapshot`, forward-only `PayoutStatus`), `NursePayoutBookingLinks`
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Handoff — after backend-phase-14 (Reviews, ratings & patient care records)
|
||||
|
||||
**The trust loop and the continuity-of-care loop are live.** A customer leaves **one moderated review per
|
||||
completed booking**; an admin/moderator publishes/hides/rejects it; the nurse's public rating is recomputed
|
||||
**from source on every transition** (so hiding a 1-star lowers the count and re-derives the average — no
|
||||
inflated-after-hide drift); low ratings auto-raise an internal `support_alert`; and nurses author **encrypted,
|
||||
patient-scoped** clinical notes readable only under a strict clinical-access rule.
|
||||
|
||||
## What the frontend (f13-b14) can now build
|
||||
- **Leave a review** — `POST bookings/{bookingId}/review` `{ rating 1–5, body?, tagCodes? }` (customer who owns a
|
||||
completed booking). Returns `{ id, moderationStatus: "pending_moderation", lowRatingAlertRaised }`. The review is
|
||||
**not public** until an admin publishes it — build the "submitted, awaiting moderation" state.
|
||||
- **Public nurse reviews** — `GET nurses/{nurseProfileId}/reviews?page=&pageSize=` → `{ aggregate: { averageRating,
|
||||
publishedCount }, reviews: PagedResult<{ id, rating, body, tagCodes[], createdAt }> }`. **Published only.**
|
||||
- **Public tag rollup** — `GET nurses/{nurseProfileId}/review_tags` → `{ publishedReviewCount, tags: [{ code,
|
||||
labelFa, labelEn, count, percentage }] }` ("% punctual"). The seeded vocab (`punctual/professional/clean/kind/
|
||||
communicative`) is always returned.
|
||||
- **Tag your own review** — `POST reviews/{reviewId}/tags` `{ tagCodes }` (author or moderator; replaces the set).
|
||||
- **Admin moderation console** — `GET admin/reviews/moderation_queue?status=&page=&pageSize=` (default
|
||||
`pending_moderation`; each row carries the linked `lowRatingAlertId` for triage) and
|
||||
`PATCH reviews/{reviewId}/status` `{ action: publish|hide|reject|unpublish, reason? }` (hide/reject need a reason).
|
||||
The PATCH returns the recomputed `{ averageRating, totalReviews }`.
|
||||
- **Patient care records** — `POST patients/{patientId}/care_records` `{ bookingId?, body }` (nurse with a
|
||||
confirmed booking) and `GET patients/{patientId}/care_records?page=&pageSize=` (owning customer / nurse with a
|
||||
confirmed booking / admin) → decrypted `{ id, patientId, bookingId, nurseProfileId, nurseName, body, recordedAt }`
|
||||
newest first. The history is **patient-scoped** — a new nurse taking over reads the whole history.
|
||||
|
||||
## Contracts
|
||||
- **`dev/contracts/domains/reviews-records.md`** — all 8 endpoints, the `moderationStatus`/action enums, tag
|
||||
codes, DTO shapes, and the care-record **access matrix**.
|
||||
- **`dev/contracts/openapi/swagger.v1.json`** refreshed — the 8 review/care-record paths are in the snapshot.
|
||||
|
||||
## What is mocked (and how it becomes real)
|
||||
- **`IReviewModerationService`** (new) — AI review pre-screen. `MockReviewModerationService` is a keyword filter:
|
||||
clean text → a human-review **Flag** by default (so the publish gate holds — reviews land `pending_moderation`);
|
||||
a banned-word substring → **Reject** (auto-hidden). Config `Seams:ReviewModeration:{AutoApproveClean,BannedWords}`.
|
||||
Make it real → a text classifier / LLM endpoint (see reports/mocks-registry.md). `ModerateReviewCommand` keeps
|
||||
decision authority + the human override, so the real impl never touches the handler.
|
||||
|
||||
## Load-bearing rules (don't regress)
|
||||
- **Recompute from source, not delta** — every publish/hide/reject/unpublish re-derives `average_rating`/
|
||||
`total_reviews` over currently-published reviews (exclude-the-changed-review then fold in its new status), in the
|
||||
same transaction, then refreshes the search index.
|
||||
- **Publish gate** — `pending_moderation`/`hidden`/`rejected` are never in a public read and never counted.
|
||||
- **1:1 per completed booking** — `UNIQUE(booking_id)` + handler pre-check; cross-tenant is a 404.
|
||||
- **Low rating (≤ config `min_rating_for_support_alert`, default 2)** raises an internal `low_rating` alert —
|
||||
internal-only, never in a user response (only its id shows on the admin queue).
|
||||
- **Care records are patient-scoped, encrypted at rest, strict access** — a nurse without a confirmed booking for
|
||||
the patient is denied read + write.
|
||||
|
||||
## Deferred (flagged, not built)
|
||||
- Two-way (nurse-reviews-customer) double-blind reviews with timed reveal.
|
||||
- First-class `incidents` entity + ML fraud scoring (manual suspension + `support_alerts` cover it now).
|
||||
- The ticket system, partner centers, and the admin **support-alert worklist console** → **b15** (this phase only
|
||||
*raises* alerts).
|
||||
- `SuspendNurse` / `ResolveSupportAlert` / `FlagConcern` admin actions → b15.
|
||||
@@ -12,6 +12,36 @@ for awareness.
|
||||
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
|
||||
-->
|
||||
|
||||
## frontend-phase-7-b8 — Booking request flow (customer request + nurse inbox) — 2026-07-09
|
||||
- **Shipped:** the money-free request phase — `services/bookingRequests` (types/keys/constants/apis[client+
|
||||
mock]/hooks + barrel) and screens **C4** `/bookings/request` (patient/variant/address/date+time + a
|
||||
first-class 3-way caregiver-gender toggle + stage-1 notes; client-side validation gates the CTA; domain
|
||||
400s surface as field/form errors; reuses f2 patients, f3 addresses + map preview, f4/search variants),
|
||||
**C5** `/bookings/request/[id]` (summary card + 3-step tracker + polled status; response countdown →
|
||||
accept flips to a 30-min payment countdown + checkout CTA / reject/expire/cancel/converted terminal
|
||||
cards; cancel-with-confirm), the **nurse inbox** `/nurse/requests` (+ nurse nav item) and **detail**
|
||||
`/nurse/requests/[id]` (only `customerNotes` + masked city/district; accept / reject-with-reason
|
||||
invalidate inbox+detail), and a `/bookings/checkout` f9 stub. Two shared tested composites:
|
||||
`CountdownTimer` (owns its 1s tick — only it re-renders; stops at zero), `BookingRequestSummaryCard`.
|
||||
i18n `booking` (fully fleshed) + `nav.requests` in both locales.
|
||||
- **Load-bearing rules honored:** deadlines are **server-frozen UTC** (client renders, never recomputes);
|
||||
`required_caregiver_gender` is explicit + client-blocks a same-gender mismatch; two-stage disclosure (the
|
||||
nurse UI never renders address/clinical fields — `get(id,'nurse')` masks); polling **stops** on a
|
||||
terminal/`converted` status.
|
||||
- **Consumes:** dev/contracts/domains/booking-requests.md (b8) — routes `booking_requests/{create,accept/{id},
|
||||
reject/{id},cancel/{id},list,get/{id}}`, wire camelCase, action-style. `services/bookingRequests/types.ts`
|
||||
derives from it.
|
||||
- **Mocked client-side:** `services/bookingRequests` via `bookingRequestsMockApi` (**USE_BOOKING_REQUESTS_MOCK
|
||||
=true, primary**) — a shared in-memory state machine so create → nurse inbox → accept/reject → C5-poll →
|
||||
lazy expiry all demo end-to-end (b8 is live, but its inputs — search/patients/addresses — are themselves
|
||||
mock-primary; and the DTO omits `variantPrice`, REQ-013). Real `bookingRequestsClientApi` maps the b8
|
||||
contract 1:1; swap is one flag. Recorded in the phase report (not mocks-registry — backend DI seams only).
|
||||
- **Gate:** npm run check green · npm run test:ci green (173 tests, +8) · npm run build green with
|
||||
NEXT_PUBLIC_API_URL set (the only prerender failure without env is the pre-existing "Missing .env
|
||||
variable!" — hits the existing `/fa/search` too, unrelated to f7).
|
||||
- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-013 variantPrice on the DTO + nurse avatar;
|
||||
REQ-014 inbox list-item variantLabel + patient age).
|
||||
|
||||
## frontend-phase-6-b7 — Search & discovery (find a verified, same-gender nurse) — 2026-07-09
|
||||
- **Shipped:** the family discovery slice — `services/search` (types/keys/constants/apis/hooks + a shared
|
||||
`filterParams.ts` C1↔C2 URL serializer) and screens **C1** `/search` (reused category grid + f3 region
|
||||
|
||||
@@ -171,3 +171,27 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
||||
- **Proposed shape:** enrich `NurseSearchResultDto` with `{ nurseName, avatarUrl, distanceKm? }`; add
|
||||
`GET api/v1/nurses/{id}/profile` returning the object above. `price`/`priceIrr` stay IRR digit-strings.
|
||||
- **Status:** open
|
||||
|
||||
## REQ-013 — Variant price on `BookingRequestDto` — filed by frontend-phase-7-b8 — 2026-07-09
|
||||
- **Need:** Add the variant's **price** (IRR digit-string) to `BookingRequestDto` (and ideally the nurse's
|
||||
**avatar URL**). The b8 DTO carries `variantLabel` + `variantPriceUnit` but no price, so the request
|
||||
summary card (C5 + the nurse detail + later f8 booking detail) can't show the priced rate from the DTO.
|
||||
- **Why:** The C5 awaiting screen and the nurse request detail render the shared `BookingRequestSummaryCard`,
|
||||
which prices the service via the f0 money util + i18n unit label. Without a price on the DTO the summary
|
||||
hides the amount on the real path. The client augments `variantPrice` behind the `services/bookingRequests`
|
||||
seam (the mock supplies it from the chosen variant; the real client leaves it `null`); adding the field
|
||||
lets the summary price the service once the domain flips to the real endpoint.
|
||||
- **Proposed shape:** `BookingRequestDto { …, variantPrice: string (IRR digits), nurseAvatarUrl?: string }`.
|
||||
(Money-free rule intact — this is the *rate* of the chosen variant for display, not an engagement total.)
|
||||
- **Status:** open
|
||||
|
||||
## REQ-014 — Enrich the nurse-inbox list item (variant label + patient age) — filed by frontend-phase-7-b8 — 2026-07-09
|
||||
- **Need:** Add `variantLabel` (and optionally the patient's **age/age-band**) to
|
||||
`BookingRequestListItemDto`. Today the nurse-inbox row carries `counterpartyName` (patient) +
|
||||
`customerNotes` + gender + times + deadline, but **not** which service was requested.
|
||||
- **Why:** The f7 nurse inbox card is specced to show the requested **service variant** and the patient's
|
||||
age alongside the gender chip + countdown. The list item omits both, so the card shows a notes preview and
|
||||
the nurse must open the detail (`get/{id}`, which *does* carry `variantLabel`) to see the service. Surfacing
|
||||
`variantLabel` on the row makes the inbox self-describing; a coarse age is a nice-to-have for triage.
|
||||
- **Proposed shape:** `BookingRequestListItemDto { …, variantLabel: string, patientAge?: int }`.
|
||||
- **Status:** open
|
||||
|
||||
@@ -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 1–7 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 | 🔴 |
|
||||
|
||||
Reference in New Issue
Block a user