12 KiB
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 Integration: docs/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 gates it on useReviewEligibility |
| 2 | /fa/bookings/[id] |
CTA «ثبت نظر» / passive «در حال بررسی» — [id]/page.tsx:52, 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). 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) |
| 5 | /fa/admin/reviews |
Moderation queue + publish/hide/reject/unpublish dialog (admin/reviews/page.tsx:61-62) — 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 selects
reviewsClientApi because USE_REVIEWS_MOCK = false
(constants.ts:14). Shapes:
docs/integration/domains/reviews.md.
| Call | Endpoint | Notes |
|---|---|---|
getReviewEligibility clientApi.ts:67 |
GET /bookings/{id}/review_eligibility |
[Authorize] BookingReviewsController.cs:33 → GetReviewEligibilityQuery.Handler.cs:24-33. 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. Not probed (would mutate the demo world) |
getNurseReviews clientApi.ts:54 |
GET /nurses/{id}/reviews |
anonymous — NursesController.cs:21,38. Probed 200 |
listModerationQueue clientApi.ts:99 |
GET /admin/reviews/moderation_queue |
AdminReviewsController.cs:20 DynamicPermission. Probed HTTP 403 |
moderateReview clientApi.ts:113 |
PATCH /reviews/{id}/status |
ReviewsController.cs:32-33 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 |
| 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; 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 — 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
- Log in as 09120000010 (customer Mohammadi) — see testing-setup.md.
- 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"). - Open
/fa/bookings/4. Expect: the passive CTA reads «مشاهدهٔ نظر شما», and/fa/bookings/4/reviewrenders the read-only card — 5 stars, the Persian body, chips «وقتشناس» + «حرفهای», status chip «منتشرشده» (published). No form. - 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 statuspending_moderation(«در انتظار بررسی»). This is the only genuinely writable review path left in the seeded world. - 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. - 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. - 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).
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_queuereturns 403 for the seededsuper_admin(09120000020) andfinance(09120000021) —DynamicPermissiongrants on the literal roleadmin; no seeded account holds it./fa/admin/reviewsis therefore a dead screen. (AdminReviewsController.cs:20)PATCH /reviews/{reviewId}/statusreturns 403 for the same reason (ReviewsController.cs:33, probed against a non-existent id so authz is provably the blocker). No review can leavepending_moderation, so the submit→publish loop is untestable and every new review is permanently invisible.- With
AutoApproveCleanabsent from config and moderation 403,publishedis only reachable via the seeder. A demo customer's review never reaches the nurse's profile. ReviewModerationStatus.Rejectedis unreachable end to end: the submit path maps a banned-wordModerationDecision.RejecttoHidden, notRejected(SubmitReviewCommand.Handler.cs:77), and the only other producer is the 403'd admin PATCH. The client still maps arejectedchip (review/page.tsx:48).GET /nurses/{id}/review_tagsis unwired — the "% of reviewers said X" rollup is served by the API (probed) but no client method exists inclientApi.ts; the profile builds chips from per-reviewtagCodesinstead.POST /reviews/{reviewId}/tagsis unwired — no screen lets an author amend tags after submit.- The client hardcodes the five tag codes in
services/reviews/types.ts:36instead of reading the server'sreview_tagsmaster. A newly seeded tag is invisible to the UI, and an unseeded code makes submit fail withUnknown 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_foundfromnot_ownerwith a200(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-12says "Mock is primary this phase" andclientApi.ts:50says "USE_REVIEWS_MOCK = true", while the flag isfalse.services/reviews/apis/mockApi.ts(14.6 KB) is now dead behind that flag.