backend phase 14 & frontend phase 7

This commit is contained in:
hamid
2026-07-09 15:30:03 +03:30
parent de53f9d8a6
commit 93cc5ecb98
101 changed files with 12930 additions and 39 deletions
+121
View File
@@ -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 15; 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` 15
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 15 / 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