# 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> }`. `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<ModerationQueueItemDto>` — `{ 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>` — `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).

---

## Refinement phase 3 additions (REQ-026/027)

- **`GET api/v1/bookings/{bookingId}/review_eligibility`** → `{ canReview, reason?:
  not_completed|already_reviewed|not_owner|not_found }`.
- **`GET api/v1/bookings/{bookingId}/my_review`** → `{ moderationStatus:
  pending_moderation|published|hidden|rejected|none, rating?, body?, tagCodes[], createdAt? }`. Masked-author
  omission on the public list is **intentional** (privacy).
- **Family-owned care plan (new entity `usr.PatientCarePlans`):** `GET/PUT api/v1/patients/{patientId}/care_record`
  → `{ patientId, medications:[{id,name,dosage?,frequency,timingNote?}], routine:[{id,label,timeOfDay?,note?}],
  tasks:[{id,label,done}] }`. Read = owner/nurse-with-booking/admin; write = owning customer only.
- **`GET api/v1/patients/{patientId}/record_access`** → `{ canView, canEdit, canAppendNote, deniedReason? }`
  (always 200; non-leaking `not_found`/`not_authorized`).
- **Structured `taskResults`** (`[{ label, done }]`) added to the visit-note write body + the history DTO.
