Files
baya-monorepo/archive/docs/flows/reviews.md
T
2026-08-02 20:01:31 +03:30

120 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Flow — reviews
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** customer (author) · admin (moderator) · public/anonymous (reader) · **Status:** partial
**Client:** real · **Server:** real (moderation half unreachable — RBAC)
**Business source:** [product/business/11-reviews-trust-and-safety.md](../../product/business/11-reviews-trust-and-safety.md)
**Integration:** [docs/integration/domains/reviews.md](../integration/domains/reviews.md)
## What it does
After a visit is finished, the family rates the nurse 15, optionally writes a note and picks trait tags. The
review is **not public on submit** — it is pre-screened, parked in `pending_moderation`, and only an admin
decision makes it visible. The nurse's public star average is derived from published reviews only, and is
**recomputed from source** on every moderation transition so hiding a bad review actually lowers the average.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| 1 | `/fa/bookings` | Completed-and-unreviewed booking shows a compact star-strip CTA — [`BookingsScreen.tsx:16,233`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/bookings/BookingsScreen.tsx) gates it on `useReviewEligibility` |
| 2 | `/fa/bookings/[id]` | CTA «ثبت نظر» / passive «در حال بررسی» — [`[id]/page.tsx:52`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/bookings/%5Bid%5D/page.tsx), `useMyReviewForBooking` fires only when `completed`/`closed` |
| 3 | `/fa/bookings/[id]/review` | The form: `RatingInput` + body (2000 chars) + `ReviewTagSelector`; moderation notice shown **before** submit ([`review/page.tsx:169-174`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/bookings/%5Bid%5D/review/page.tsx)). Already-reviewed → a read-only state card, never a second form (`:93`) |
| 4 | `/fa/search/nurse/[nurseId]` | «نظرات» tab — aggregate + infinite published list ([`page.tsx:288`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/search/nurse/%5BnurseId%5D/page.tsx)) |
| 5 | `/fa/admin/reviews` | Moderation queue + publish/hide/reject/unpublish dialog ([`admin/reviews/page.tsx:61-62`](../../client/src/app/%5Blocale%5D/%28private-routes%29/admin/reviews/page.tsx)) — **renders an error state today, see Known gaps** |
| — | *(none)* | **The nurse has no screen to read their own reviews.** No route consumes `useNurseReviews` outside step 4 |
## API
Client seam: [`services/reviews/apis/index.ts:11`](../../client/src/services/reviews/apis/index.ts) selects
`reviewsClientApi` because `USE_REVIEWS_MOCK = false`
([`constants.ts:14`](../../client/src/services/reviews/constants.ts)). Shapes:
[docs/integration/domains/reviews.md](../integration/domains/reviews.md).
| Call | Endpoint | Notes |
| --- | --- | --- |
| `getReviewEligibility` [`clientApi.ts:67`](../../client/src/services/reviews/apis/clientApi.ts) | `GET /bookings/{id}/review_eligibility` | `[Authorize]` [`BookingReviewsController.cs:33`](../../server/src/API/Baya.Web.Api/Controllers/V1/BookingReviewsController.cs) → [`GetReviewEligibilityQuery.Handler.cs:24-33`](../../server/src/Core/Baya.Application/Features/Reviews/Queries/GetReviewEligibility/GetReviewEligibilityQuery.Handler.cs). **Probed 200** |
| `getMyReviewForBooking` `clientApi.ts:78` | `GET /bookings/{id}/my_review` | `BookingReviewsController.cs:39`. **Probed 200** |
| `createReview` `clientApi.ts:91` | `POST /bookings/{id}/review` | `BookingReviewsController.cs:26` → [`SubmitReviewCommand.Handler.cs`](../../server/src/Core/Baya.Application/Features/Reviews/Commands/SubmitReview/SubmitReviewCommand.Handler.cs). Not probed (would mutate the demo world) |
| `getNurseReviews` `clientApi.ts:54` | `GET /nurses/{id}/reviews` | **anonymous** — [`NursesController.cs:21,38`](../../server/src/API/Baya.Web.Api/Controllers/V1/NursesController.cs). **Probed 200** |
| `listModerationQueue` `clientApi.ts:99` | `GET /admin/reviews/moderation_queue` | [`AdminReviewsController.cs:20`](../../server/src/API/Baya.Web.Api/Controllers/V1/AdminReviewsController.cs) `DynamicPermission`. **Probed HTTP 403** |
| `moderateReview` `clientApi.ts:113` | `PATCH /reviews/{id}/status` | [`ReviewsController.cs:32-33`](../../server/src/API/Baya.Web.Api/Controllers/V1/ReviewsController.cs) `DynamicPermission`. **Probed HTTP 403** (authz fires before the handler) |
| *(no client method)* | `GET /nurses/{id}/review_tags` | anonymous, `NursesController.cs:44`. **Probed 200** — returns the code/labelFa/labelEn/count/percentage rollup. **Unwired** |
| *(no client method)* | `POST /reviews/{id}/tags` | `ReviewsController.cs:27`, plain `[Authorize]`. Probed with a customer token: **404 "Review not found"** — authz passes, no UI calls it |
## Rules that must hold
| Rule | Value | Source of truth |
| --- | --- | --- |
| Rating range | `1``5` | DB CHECK `CK_Reviews_Rating` (**HARDCODED**) · [business/11](../../product/business/11-reviews-trust-and-safety.md) |
| One review per booking | `UNIQUE(booking_id)` on `reviews.Reviews` | `ReviewConfig.cs` (**HARDCODED DB**); handler pre-checks and returns `409` (`SubmitReviewCommand.Handler.cs:57`) |
| Reviewable only when finished | booking status `completed` or `closed` | `SubmitReviewCommand.Handler.cs:54`; eligibility is **server-decided**, the client never infers it |
| Low-rating alert threshold | `≤ 2``SupportAlertType.LowRating` | CONFIG key `min_rating_for_support_alert`; `SubmitReviewCommand.Handler.cs:112-119` |
| Not public on submit | clean text → `pending_moderation` (a human-review *flag*) unless `Seams:ReviewModeration:AutoApproveClean` | [`MockReviewModerationService.cs:28`](../../server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockReviewModerationService.cs); the key is **absent from `appsettings*.json`** so the default `false` holds |
| Only `published` is public and counted | hidden/rejected/pending excluded from both the list and the aggregate | `ReviewModerationStatus.cs:12-22`; **verified live** — nurse 2 holds a seeded hidden 1★ and returns `averageRating 0.00 / publishedCount 0` |
| **Aggregate recomputed from source, never incremented** | `COUNT`/`SUM(rating)` over the nurse's published reviews *excluding* the transitioning row, then the row's **new** status folded in; average rounded to 2 dp away-from-zero | [`RecomputeNurseRating.cs:33-43`](../../server/src/Core/Baya.Application/Features/Reviews/RecomputeNurseRating.cs) — **verified in the handler, no `+= 1` path exists.** Called by `ModerateReviewCommand.Handler.cs:45` on **every** transition and by an auto-published/auto-hidden submit (`SubmitReviewCommand.Handler.cs:101`) |
| Recompute is atomic with the transition | status change + aggregate + b7 search-index reindex staged on one unit of work, one `CommitAsync` | `ModerateReviewCommand.Handler.cs:42-47`; `RecomputeNurseRating.cs:46` |
| Public row carries no author | probed payload is `{id, rating, body, tagCodes, createdAt}`**no author field at all**, stronger than the masking REQ-026 describes | live probe of `GET /nurses/1/reviews` |
| Tag codes are codes | labels are i18n keys; never render a raw code | `review/page.tsx:123,202` fall back to the code only when the key is missing |
## How to test
1. Log in as **09120000010** (customer Mohammadi) — see [testing-setup.md](testing-setup.md).
2. Open `/fa/bookings`. **Expect:** bookings 1, 2, 3, 4, 7, 8, 10 listed; only **booking 8** shows the
star-strip «ثبت نظر» CTA (probed: `booking 8 → canReview: true`; bookings 3 and 4 →
`canReview: false, reason: "already_reviewed"`).
3. Open `/fa/bookings/4`. **Expect:** the passive CTA reads «مشاهدهٔ نظر شما», and `/fa/bookings/4/review`
renders the read-only card — 5 stars, the Persian body, chips «وقت‌شناس» + «حرفه‌ای», status chip
«منتشرشده» (`published`). No form.
4. Open `/fa/bookings/8/review`. **Expect:** the form, with the blue moderation notice above it. Submit 4
stars + any body. **Expect:** the page flips to the state card with status `pending_moderation`
(«در انتظار بررسی»). *This is the only genuinely writable review path left in the seeded world.*
5. Open `/fa/search/nurse/1` → «نظرات» tab. **Expect:** average **۵٫۰** and **۱** review — the single
published 5★. The review you just wrote in step 4 must **not** appear.
6. Anonymous check (no login): `curl -s --noproxy '*' http://localhost:5002/api/v1/nurses/2/reviews`.
**Expect:** `averageRating 0.00, publishedCount 0` — nurse 2 carries a seeded **hidden** 1★ and it is
correctly excluded from both the list and the aggregate. This is the from-source recompute proof.
7. Log in as **09120000020** (super_admin) and open `/fa/admin/reviews`. **Expect (today):** an error state,
not a queue — the endpoint returns **403**. See Known gaps.
**Seeded-world caveat:** three reviews exist — booking 4 `published` 5★ (nurse 1), booking 3
`pending_moderation` 4★ (nurse 1), one `hidden` 1★ (nurse 2)
([`DemoLifecycleSeeder.Social.cs:29-85`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleSeeder.Social.cs)).
Because moderation 403s, **no review submitted during testing can ever become public** — the publish half of
the loop is not demonstrable end to end.
## Known gaps
- `GET /admin/reviews/moderation_queue` returns **403** for the seeded `super_admin` (09120000020) and
`finance` (09120000021) — `DynamicPermission` grants on the literal role `admin`; no seeded account holds
it. `/fa/admin/reviews` is therefore a dead screen. (`AdminReviewsController.cs:20`)
- `PATCH /reviews/{reviewId}/status` returns **403** for the same reason (`ReviewsController.cs:33`, probed
against a non-existent id so authz is provably the blocker). **No review can leave `pending_moderation`**,
so the submit→publish loop is untestable and every new review is permanently invisible.
- With `AutoApproveClean` absent from config and moderation 403, `published` is only reachable via the
seeder. A demo customer's review never reaches the nurse's profile.
- `ReviewModerationStatus.Rejected` is unreachable end to end: the submit path maps a banned-word
`ModerationDecision.Reject` to **`Hidden`**, not `Rejected` (`SubmitReviewCommand.Handler.cs:77`), and the
only other producer is the 403'd admin PATCH. The client still maps a `rejected` chip
(`review/page.tsx:48`).
- `GET /nurses/{id}/review_tags` is **unwired** — the "% of reviewers said X" rollup is served by the API
(probed) but no client method exists in `clientApi.ts`; the profile builds chips from per-review `tagCodes`
instead.
- `POST /reviews/{reviewId}/tags` is **unwired** — no screen lets an author amend tags after submit.
- The client hardcodes the five tag codes in
[`services/reviews/types.ts:36`](../../client/src/services/reviews/types.ts) instead of reading the server's
`review_tags` master. A newly seeded tag is invisible to the UI, and an unseeded code makes submit fail with
`Unknown review tag(s)` (`SubmitReviewCommand.Handler.cs:67`).
- **The nurse cannot see their own reviews.** No nurse route consumes any reviews hook — a nurse learns of a
1★ only through the notification the moderation handler dispatches (`ModerateReviewCommand.Handler.cs:54`),
which itself cannot fire while moderation 403s.
- Eligibility distinguishes `not_found` from `not_owner` with a `200`
(`GetReviewEligibilityQuery.Handler.cs:24-27`), letting any authenticated caller probe whether an arbitrary
booking id exists. Contradicts the repo's 404-not-403 tenancy invariant (INV-7).
- The low-rating support alert is raised on submit (rating ≤ 2) but has **no reachable triage surface**
admin support-alert reads are behind the same 403.
- Stale doc comments assert the opposite of the shipped config: `constants.ts:5-12` says "**Mock is primary
this phase**" and `clientApi.ts:50` says "`USE_REVIEWS_MOCK = true`", while the flag is `false`.
`services/reviews/apis/mockApi.ts` (14.6 KB) is now dead behind that flag.