# Contract — Reviews & Patient Care Records (backend phase b14) > The trust-and-continuity surface: a customer leaves **one moderated review per completed booking**; an > admin/moderator transitions it (recomputing the nurse's public rating from source on every transition); the > public reads only ever see published reviews + the aggregate; and nurses author **encrypted, patient-scoped** > clinical notes readable only under a strict clinical-access rule. Assumes > [`../conventions/api-conventions.md`](../conventions/api-conventions.md) + > [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema: > [`../openapi/swagger.v1.json`](../openapi/swagger.v1.json). **Status:** live as of backend-phase-b14 · **Frontend consumer:** frontend-phase-f13-b14 There is **no money** in this domain. Ratings are integers 1–5; the aggregate `averageRating` is a decimal (2-dp, e.g. `4.5`). Timestamps are ISO-8601. List query params are **camelCase** (`page`, `pageSize`, `status`). The response envelope is the standard `{ data, … }`; the shapes below are the `data`. ## Enums used - `moderationStatus`: `pending_moderation` | `published` | `hidden` | `rejected`. A review is born `pending_moderation` and is **never public / never counted** until `published`. Only `published` reviews are returned by any public read and only `published` reviews feed the aggregate. - Moderation **action** (the `PATCH` body): `publish` | `hide` | `reject` | `unpublish`. `hide`/`reject` require a `reason`; `unpublish` returns a published review to `pending_moderation`. - Review **tag codes** (seeded vocabulary): `punctual` | `professional` | `clean` | `kind` | `communicative`. ## Reviews ### `POST api/v1/bookings/{bookingId}/review` - **Purpose:** The customer submits the one review for a completed booking. - **Auth:** authenticated customer who **owns** the booking (tenancy enforced in the handler). - **Path params:** `bookingId` (long). - **Request body:** `{ "rating": 5, "body": "great care", "tagCodes": ["punctual","kind"] }` — `rating` 1–5 required; `body` optional (≤ 2000); `tagCodes` optional (validated against the active vocabulary). - **Success `200` (`data`):** `SubmitReviewResult` — `{ id, moderationStatus, lowRatingAlertRaised }`. The status is `pending_moderation` by default (the AI pre-screen keeps clean text pending; a banned-word hit auto-hides). - **Failure cases:** `400` rating out of 1–5 / unknown tag code; `401` unauthenticated; `403` caller is not a customer; `404` booking not found **or not owned** (a cross-tenant booking is a not-found, never a leak); a plain failure when the booking is **not completed/closed**; `409` the booking is **already reviewed** (1:1). - **Notes:** A rating **≤ `min_rating_for_support_alert`** (config, default 2) raises an internal `low_rating` `support_alert` (never surfaced on any user response). The new review does **not** appear in the public list until published. ### `POST api/v1/reviews/{reviewId}/tags` - **Purpose:** Replace a review's standardized tags with exactly the requested set. - **Auth:** the review's **author** or a moderator (admin/super_admin/moderation) — enforced in the handler. - **Path params:** `reviewId` (long). **Request body:** `{ "tagCodes": ["punctual","professional"] }`. - **Success `200` (`data`):** `ReviewTagsResult` — `{ reviewId, tagCodes }`. - **Failure cases:** `400` unknown tag code; `401`; `403` not the author and not a moderator; `404` review not found. The `UNIQUE(reviewId, tagCode)` forbids a duplicate tag (the set is de-duplicated server-side). ### `PATCH api/v1/reviews/{reviewId}/status` - **Purpose:** Admin/moderator moderation transition (the human decision authority; always overrides the AI). - **Auth:** admin / moderator (dynamic-permission policy). - **Path params:** `reviewId` (long). **Request body:** `{ "action": "publish", "reason": null }` — `hide`/`reject` require a non-empty `reason` (≤ 500). - **Success `200` (`data`):** `ModerateReviewResult` — `{ id, moderationStatus, averageRating, totalReviews }` (the recomputed-from-source nurse aggregate). - **Failure cases:** `400` unknown action / missing reason on hide|reject; `401`; `403` non-admin; `404` review not found. - **Notes:** **Every** transition recomputes `nurse_profiles.averageRating`/`totalReviews` from the nurse's currently-`published` reviews (not an incremental delta) and refreshes the search index — in the **same transaction** as the status change — so hiding a low rating lowers the count and re-derives the average. ### `GET api/v1/nurses/{nurseProfileId}/reviews` — public - **Purpose:** The public reviews for a nurse: the rating aggregate + a page of **published** reviews. - **Auth:** anonymous. **Path params:** `nurseProfileId` (long). **Query:** `page` (default 1), `pageSize` (default 20, max 100). - **Success `200` (`data`):** `NurseReviewsResult` — `{ aggregate: { averageRating, publishedCount }, reviews: PagedResult }`. `ReviewListItemDto` = `{ id, rating, body, tagCodes[], createdAt }` — **never** carries moderation internals. An unknown nurse returns a zero aggregate + empty page (`200`). - **Notes:** The publish gate is enforced at the query layer — a `pending_moderation`/`hidden`/`rejected` review is never returned and never counted. The aggregate is cached and invalidated on every transition. ### `GET api/v1/nurses/{nurseProfileId}/review_tags` — public - **Purpose:** The per-nurse tag rollup ("% punctual") over published reviews. - **Auth:** anonymous. **Path params:** `nurseProfileId` (long). - **Success `200` (`data`):** `NurseTagAggregatesResult` — `{ publishedReviewCount, tags: TagAggregateDto[] }` where `TagAggregateDto` = `{ code, labelFa, labelEn, count, percentage }` (percentage of published reviews, 1-dp). The active seeded vocabulary is always returned (zero counts for a nurse with no published reviews). ### `GET api/v1/admin/reviews/moderation_queue` — admin - **Purpose:** The moderation worklist. - **Auth:** admin / moderator. **Query:** `status` (default `pending_moderation`; any `moderationStatus`), `page`, `pageSize`. - **Success `200` (`data`):** `PagedResult` — `{ id, bookingId, nurseProfileId, customerProfileId, rating, body, moderationStatus, moderationReason, lowRatingAlertId, createdAt }`. The `lowRatingAlertId` is the linked internal alert (id only — support alerts stay internal). - **Failure cases:** `400` unknown status; `401`; `403` non-admin. ## Patient care records Clinical bodies are **encrypted at rest** and returned **decrypted only after the access check passes**. The access rule is enforced in the handler, not just the route policy. **Access matrix** (both endpoints): | Caller | Write | Read | | --- | --- | --- | | Nurse with a **confirmed** (or in-progress/completed/disputed/closed) booking for the patient | ✅ | ✅ | | Nurse **without** such a booking | ❌ `403` | ❌ `403` | | The patient's **owning customer** | ❌ (only nurses author) | ✅ | | Admin / super_admin | ❌ (only nurses author) | ✅ | | Anyone else | ❌ | ❌ `403` | ### `POST api/v1/patients/{patientId}/care_records` - **Purpose:** A nurse authors a clinical note for a patient (patient-scoped; the note is encrypted before persist). - **Auth:** authenticated nurse with a qualifying booking for the patient. - **Path params:** `patientId` (long). **Request body:** `{ "bookingId": 123, "body": "…" }` — `bookingId` optional provenance (which visit produced the note); `body` required (≤ 8000). - **Success `200` (`data`):** `WriteCareRecordResult` — `{ id, patientId, recordedAt }`. - **Failure cases:** `401`; `403` caller is not a nurse **or** has no qualifying booking for the patient; `404` patient not found; `400` empty body. ### `GET api/v1/patients/{patientId}/care_records` - **Purpose:** The patient-scoped longitudinal history, newest first. - **Auth:** owning customer / nurse with a qualifying booking / admin (see the matrix). - **Path params:** `patientId` (long). **Query:** `page`, `pageSize`. - **Success `200` (`data`):** `PagedResult` — `CareRecordDto` = `{ id, patientId, bookingId, nurseProfileId, nurseName, body, recordedAt }` (`body` decrypted). Ordered by `recordedAt DESC`. - **Failure cases:** `401`; `403` no clinical access; `404` patient not found. - **Notes:** The record is **patient-scoped, not booking-scoped** — a new nurse taking over reads the whole history (not just their own booking's notes).