backend phase 14 & frontend phase 7
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
# 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).
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user