# 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 1–5, 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.
