# Backend Phase 14 — Reviews, ratings & patient care records — Report

**Date:** 2026-07-09 · **Track:** backend · **Status:** complete, gate green.

Closes the trust loop (moderated reviews + an honest, recomputed-from-source public rating + low-rating safety
alerts) and the continuity-of-care loop (encrypted, patient-scoped clinical notes under strict access).

## What was built

### Schema (one additive migration `ReviewsAndPatientCareRecords`, `reviews` schema)
- **`Reviews`** — one per completed booking. `UNIQUE(booking_id)` (1:1), `CHECK(rating BETWEEN 1 AND 5)`, guarded
  `moderation_status` (+ reason/moderator/time), soft-delete filter. Indexes: unique `booking_id`,
  `(nurse_profile_id, moderation_status)`, `moderation_status`. Marked `IAuditable` → the SaveChanges interceptor
  writes an append-only `audit_logs` diff on creation and every moderation transition.
- **`ReviewTagsMaster`** — seeded vocabulary (`punctual/professional/clean/kind/communicative`), `UNIQUE(code)`.
- **`ReviewTagLinks`** — N:N join, `UNIQUE(review_id, review_tag_master_id)`.
- **`PatientCareRecords`** — nurse-authored, **patient-scoped** (`patient_id`, not booking), nullable `booking_id`
  provenance, `body_encrypted` (ciphertext, no EF converter), `(patient_id, recorded_at)` index, soft-delete.
- **`NurseProfile`** gained `SetReviewAggregates(avg, count)` — the single sanctioned write for the existing
  `AverageRating`/`TotalReviews` columns (no new aggregate table).

### Features (CQRS, `Baya.Application/Features/`)
- **Reviews/**: `SubmitReviewCommand`, `ModerateReviewCommand`, `AttachReviewTagsCommand`,
  `ListReviewsForNurseQuery`, `GetReviewModerationQueueQuery`, `GetTagAggregatesQuery`; plus the internal
  `RecomputeNurseRating` from-source helper and `ReviewCache` (cached public aggregate + eviction).
- **PatientCareRecords/**: `WritePatientCareRecordCommand`, `GetPatientHistoryQuery`.

### Endpoints (5 controllers)
| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `POST /v1/bookings/{bookingId}/review` | SubmitReview | customer (owns booking) |
| `POST /v1/reviews/{reviewId}/tags` | AttachReviewTags | author / moderator |
| `PATCH /v1/reviews/{reviewId}/status` | ModerateReview | admin / moderator |
| `GET /v1/nurses/{nurseProfileId}/reviews` | ListReviewsForNurse | public |
| `GET /v1/nurses/{nurseProfileId}/review_tags` | GetTagAggregates | public |
| `GET /v1/admin/reviews/moderation_queue` | GetReviewModerationQueue | admin |
| `POST /v1/patients/{patientId}/care_records` | WritePatientCareRecord | nurse (confirmed booking) |
| `GET /v1/patients/{patientId}/care_records` | GetPatientHistory | owner / nurse / admin |

### Key design decisions (non-obvious)
- **Recompute is exclude-and-fold, in one transaction.** A fresh LINQ query can't see the tracked, uncommitted
  status change, so `RecomputeNurseRating` reads `COUNT`/`SUM(rating)` over the nurse's published reviews
  **excluding the transitioning review** (id `0` for a brand-new one), then folds in that review's *new* status in
  memory. Correct-from-source and single-commit — no `+delta`/`-delta`, no stale re-query, no second commit.
- **Care records encrypt manually (no EF value converter).** Unlike the b3/b9 converter-backed columns,
  `body_encrypted` stores ciphertext directly; the handler `Encrypt`s on write and `Decrypt`s only after the access
  check passes — so no query path (list, projection) can ever surface plaintext. `recorded_at` is `DateTime` (UTC),
  not `DateTimeOffset`, so the SQLite test provider can `ORDER BY` it.
- **AI verdict → initial disposition.** `SubmitReview` calls `IReviewModerationService.ScreenAsync`; the verdict
  maps to the initial status (`Approve`→published, `Reject`→hidden, else pending). The mock defaults clean text to
  a human-review **Flag** so the **publish gate holds by default** (reviews land `pending_moderation`);
  `Seams:ReviewModeration:AutoApproveClean` opts into auto-publish. Human `ModerateReview` always overrides.

## What is now testable and exactly how (the phase §7 steps)
Run the API against SQL Server; use Swagger/curl.
1. **Submit on a completed booking → accepted.** `POST bookings/{completedId}/review {rating:5}` as the owner →
   `200`, `moderationStatus: pending_moderation`. It does **not** appear in `GET nurses/{id}/reviews` (publish gate).
2. **Submit on a cancelled booking → rejected;** a second submit on an already-reviewed booking → conflict (1:1).
3. **Moderate publish recomputes up.** `PATCH reviews/{id}/status {action:"publish"}` → the review appears in the
   public list; the aggregate `publishedCount` increments and `averageRating` reflects it.
4. **Moderate hide recomputes down.** Publish a 5★ and a 1★, then `hide` the 1★ → the public list drops it and the
   aggregate rises / count decrements (re-derived from source).
5. **Low rating raises an alert.** `POST … {rating:1}` → a `low_rating` `support_alerts` row exists (visible only on
   the admin/internal path — never in a user response; its id shows on the moderation queue row).
6. **Write + read a care record.** As a nurse with a confirmed booking, `POST patients/{P}/care_records` → `200`;
   the stored column is ciphertext. As the owning customer or that nurse, `GET …` → decrypted note, newest first.
7. **Unauthorized nurse denied.** A nurse with no confirmed booking for P → `403` on both write and read.

**Automated coverage:** 13 Foundation handler tests (`Baya.Test.Foundation/Reviews/`) cover steps 1–7 incl. the
recompute-from-source math (hide lowers count **and** average) and ciphertext-at-rest; 4 API tests
(`Baya.Test.Api/ReviewsApiTests.cs`) cover the HTTP pipeline (public reads anonymous, admin/patient reads 401
without a token). Full suite: **346 pass**, build clean (0 new warnings).

## What is mocked / waiting on a real service
- **`IReviewModerationService`** (introduced here) — `MockReviewModerationService` (keyword filter / pass-through),
  config `Seams:ReviewModeration:{AutoApproveClean,BannedWords}`. Make it real → a text classifier / LLM endpoint,
  registration-only swap (see reports/mocks-registry.md, row → 🟡). `ModerateReviewCommand` keeps decision authority.
- Reused seams: `IFieldEncryptor` (clinical notes), `ISearchIndexMaintainer` (aggregate refresh),
  `ISupportAlertService` (low-rating alert), `INotificationDispatcher` (review-outcome notice), `IPlatformConfig`
  (`min_rating_for_support_alert`), `ICacheService` (aggregate cache).

## Contracts produced
- `dev/contracts/domains/reviews-records.md` (8 endpoints, enums, DTOs, care-record access matrix).
- `dev/contracts/openapi/swagger.v1.json` refreshed (all 8 paths present).

## Follow-ups for later phases (b15)
- Ticket system, partner centers, and the admin **support-alert worklist console** (this phase only *raises*
  `low_rating` alerts).
- `SuspendNurse` / `ResolveSupportAlert` / `FlagConcern` admin actions.
- Deferred by design: two-way double-blind reviews with timed reveal; a first-class `incidents` entity + ML fraud
  scoring; optional structured `vitals_encrypted` on care records.
