# 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.