340 lines
27 KiB
Markdown
340 lines
27 KiB
Markdown
# Backend Phase 14 — Reviews, ratings & patient care records
|
||
|
||
> **Mission:** close the trust loop and the continuity-of-care loop. Let a customer leave **one
|
||
> moderated review per completed booking**, run that review through a moderation pipeline, and keep the
|
||
> nurse's public rating **honest** by recomputing it from source on *every* status transition — so hiding
|
||
> a 1-star never leaves an inflated average. Auto-raise an internal safety alert on low ratings. Separately,
|
||
> let nurses author **encrypted, patient-scoped clinical notes** that accumulate into a longitudinal care
|
||
> history a new nurse can read before taking over — under strict clinical access control. This is a
|
||
> brand-survival area: buyers are vulnerable people cared for unobserved at home.
|
||
>
|
||
> **Track:** backend · **Depends on:** [backend-phase-9](backend-phase-9.md) (completed bookings + dispute window), [backend-phase-3](backend-phase-3.md) (profiles/patients), [backend-phase-1](backend-phase-1.md) (`support_alerts`, `platform_configs`, `audit_logs`, notifications), [backend-phase-7](backend-phase-7.md) (search aggregates) · **Unlocks:** the reviews UI ([frontend-phase-13-b14](../frontend/frontend-phase-13-b14.md))
|
||
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
|
||
|
||
---
|
||
|
||
## 1. Context — where this sits
|
||
|
||
This is the trust-and-continuity phase. By now bookings can reach a **completed/closed** state (b9), the
|
||
nurse and customer profiles + patients exist (b3), the platform can raise internal `support_alerts` and
|
||
read config (b1), and the search index carries a denormalized nurse rating/count that must stay current
|
||
(b7). This phase turns those pieces into the two things families actually judge a marketplace on after the
|
||
visit: **a trustworthy public rating** and **clinical continuity across nurses**.
|
||
|
||
Two distinct sub-domains live here, and they must not be conflated:
|
||
|
||
1. **Reviews & ratings** — public, social-proof, moderated, aggregate-driving.
|
||
2. **Patient care records** — private, clinical, encrypted, **patient-scoped** (not booking-scoped),
|
||
accessed only by people with a clinical right to see them.
|
||
|
||
**What already exists (do not rebuild):**
|
||
|
||
- **Completed bookings + dispute window** — [backend-phase-9](backend-phase-9.md) built `bookings` with the
|
||
3-amount split, the lifecycle that reaches a **completed/closed** status, `booking_sessions`,
|
||
`booking_care_instructions` (the two-stage clinical disclosure gate), `visit_verifications` (EVV), and set
|
||
`dispute_window_ends_at = completed_at + dispute_window_hours` on completion. Read these statuses and
|
||
relations; do not re-model booking lifecycle.
|
||
- **Profiles & patients** — [backend-phase-3](backend-phase-3.md) built `customer_profiles`,
|
||
`nurse_profiles` (including the denormalized **aggregate rating/count fields** you recompute here), and
|
||
`patients` (with customer tenancy). The aggregate columns on `nurse_profiles` are *owned* by the nurse
|
||
domain but **written by this phase** on every review transition.
|
||
- **Platform signals & config** — [backend-phase-1](backend-phase-1.md) built `support_alerts` (the
|
||
internal-only staff worklist + `RaiseSupportAlert` API), `platform_configs` (the typed cached accessor —
|
||
including `min_rating_for_support_alert`), `audit_logs` (append-only, written by the SaveChanges
|
||
interceptor on sensitive entities), and the in-app `notifications` write. **Reuse all of these** — you
|
||
*raise* alerts, you do not define the table.
|
||
- **Search aggregates** — [backend-phase-7](backend-phase-7.md) built `nurse_search_index` behind the
|
||
`INurseSearch` seam, with **maintenance hooks** to refresh a nurse's denormalized rating/count. Every
|
||
review transition that changes the nurse aggregate must trigger that refresh — do not write the search
|
||
index directly; call the b7 maintenance hook.
|
||
- **Cross-cutting seams** — [backend-phase-0](backend-phase-0.md) introduced `IFieldEncryptor` (the field
|
||
encryptor you use for clinical notes), `ICacheService`, `IDateTimeProvider`, and `INotificationDispatcher`.
|
||
**Reuse `IFieldEncryptor`** for `patient_care_records`; do not introduce a new encryption seam.
|
||
|
||
> The **ticket/messaging** system (`tickets`, `ticket_participants`, `ticket_messages`), `partner_centers`,
|
||
> and the admin support-alert *worklist console* land in [backend-phase-15](backend-phase-15.md). This phase
|
||
> *raises* alerts and *consumes* the existing `support_alerts` raise API; it does **not** build the ticket
|
||
> system or the alert worklist UI. **(DEFERRED → b15.)**
|
||
|
||
## 2. Required reading (do this first)
|
||
|
||
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
|
||
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
|
||
- **Product — business rules (source of truth):**
|
||
[`product/business/11-reviews-trust-and-safety.md`](../../../product/business/11-reviews-trust-and-safety.md)
|
||
— the one-per-completed-booking rule, recompute-on-every-transition, the configurable low-rating threshold,
|
||
the "patient is not the sole information source" principle, and why this is a brand-survival area.
|
||
- **Product — data model (source of truth):**
|
||
[`product/data-model/10-reviews-and-records.md`](../../../product/data-model/10-reviews-and-records.md) —
|
||
`reviews` (rating 1–5 CHECK, body, moderation status + fields; 1:1 → `bookings`, N:1 →
|
||
`customer_profiles`/`nurse_profiles`), `review_tags_master`/`review_tag_links` (N:N), and
|
||
`patient_care_records` (nurse-authored, encrypted, **patient-scoped**, strict access). Read the **"Why"**
|
||
notes — they encode the guards you must enforce.
|
||
- **Booking statuses you gate on** — re-read the `bookings` status enum and `dispute_window_ends_at` from
|
||
[backend-phase-9](backend-phase-9.md)'s contract (`dev/contracts/domains/bookings.md`) so you key review
|
||
eligibility off the *exact* completed/closed status values, not a guess.
|
||
- **Config & alerts you reuse** — [backend-phase-1](backend-phase-1.md)'s handoff and
|
||
`dev/contracts/domains/platform-signals.md` (or equivalent) for the `RaiseSupportAlert` signature, the
|
||
`support_alerts` shape, and the typed config accessor for `min_rating_for_support_alert`.
|
||
- **Search refresh you trigger** — [backend-phase-7](backend-phase-7.md)'s handoff for the
|
||
`INurseSearch` maintenance hook that refreshes a nurse's aggregate rating/count in `nurse_search_index`.
|
||
- **Code to mirror (existing patterns):** an existing feature folder under
|
||
`Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/` (request `record` + `internal sealed`
|
||
handler + `OperationResult`), an `IEntityTypeConfiguration<T>` under
|
||
`Persistence/Configuration/<Area>Config/`, a controller under `Baya.Web.Api/Controllers/V1/`
|
||
(`sealed`, `BaseController`, `ISender`, `base.OperationResult(...)`), and how prior phases call
|
||
`IFieldEncryptor` for encrypted columns (b3 IBAN, b9 care instructions).
|
||
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
|
||
(envelope, routes, status codes) and [`money-and-types.md`](../../contracts/conventions/money-and-types.md)
|
||
(this phase has **no money**, but follow the type/format rules for ids/enums/timestamps).
|
||
|
||
## 3. Scope — build this
|
||
|
||
A vertical slice per capability: entity + EF config + migration → command/query handler(s) → controller
|
||
endpoint → contract. Everything async with `CancellationToken`; reads are `AsNoTracking()` + `.Select()`
|
||
projection + pagination; writes go through `IUnitOfWork` with a single `CommitAsync`.
|
||
|
||
### 3.1 Entities, configs & migration
|
||
|
||
Add these tables (exact names below) as a single additive EF Core migration. One
|
||
`IEntityTypeConfiguration<T>` per entity in `Persistence/Configuration/ReviewsConfig/`.
|
||
|
||
- **`reviews`** — one review per **completed** booking.
|
||
- Columns: `id` (BIGINT PK), `booking_id` (FK → `bookings`, **UNIQUE** — enforces 1:1),
|
||
`customer_profile_id` (FK → `customer_profiles`), `nurse_profile_id` (FK → `nurse_profiles`),
|
||
`rating` (TINYINT/INT, **CHECK `rating BETWEEN 1 AND 5`**), `body` (NVARCHAR, nullable free text),
|
||
`moderation_status` (enum: `pending_moderation` | `published` | `hidden` | `rejected`; default
|
||
`pending_moderation`), `moderation_reason` (NVARCHAR, nullable — set on hide/reject),
|
||
`moderated_by_id` (FK → users, nullable), `moderated_at` (datetimeoffset, nullable), plus the audit
|
||
fields stamped by the interceptor (`CreatedAt/ModifiedAt/CreatedById/ModifiedById`) and soft-delete.
|
||
- Indexes: unique on `booking_id`; index on `(nurse_profile_id, moderation_status)` for the public
|
||
published-only list and the recompute query; index on `moderation_status` for the moderation queue.
|
||
- Soft-delete query filter (`!IsDeleted`).
|
||
- **`review_tags_master`** — standardized tag vocabulary.
|
||
- Columns: `id` (BIGINT PK), `code` (e.g. `punctual`, `professional`, `clean`, `kind`; UNIQUE),
|
||
`label_fa` (NVARCHAR), `label_en` (NVARCHAR), `is_active` (BIT), display order. **Seed** a starter
|
||
vocabulary (at minimum: `punctual`, `professional`, `clean`, `kind`, `communicative`).
|
||
- **`review_tag_links`** — N:N join `reviews` ↔ `review_tags_master`.
|
||
- Columns: `id` (BIGINT PK) or composite key, `review_id` (FK), `review_tag_master_id` (FK),
|
||
**UNIQUE `(review_id, review_tag_master_id)`** (no duplicate tag on a review).
|
||
- **`patient_care_records`** — nurse-authored, **encrypted**, **patient-scoped** clinical notes.
|
||
- Columns: `id` (BIGINT PK), `patient_id` (FK → `patients`, **the scoping key — not `booking_id`**),
|
||
`booking_id` (FK → `bookings`, **nullable**, provenance only — which visit produced the note),
|
||
`nurse_profile_id` (FK → `nurse_profiles`, the author), `body_encrypted` (VARBINARY/NVARCHAR holding
|
||
the `IFieldEncryptor`-encrypted clinical note — **never plaintext**), optional encrypted structured
|
||
fields (e.g. `vitals_encrypted`) if the digest schema carries them, `recorded_at` (datetimeoffset),
|
||
plus audit + soft-delete.
|
||
- Indexes: `(patient_id, recorded_at DESC)` for the longitudinal history read.
|
||
|
||
> **Aggregate columns are not yours to add.** `nurse_profiles` already carries the denormalized
|
||
> `average_rating` / `review_count` (or equivalently named) columns from b3. You **write** them on every
|
||
> transition; you do not re-create them. If they are missing, add them to the b3 entity in place and note
|
||
> it in your report (do not fork a parallel aggregate table).
|
||
|
||
### 3.2 Reviews — commands & queries
|
||
|
||
Feature folder `Baya.Application/Features/Reviews/`.
|
||
|
||
- **`SubmitReviewCommand`** (`Commands/SubmitReview/`) — customer submits `rating` (1–5) + `body` (+ optional
|
||
tag codes) for a booking.
|
||
- Guards (return `OperationResult.FailureResult`/`NotFoundResult`, **never throw**): the booking exists
|
||
and is owned by the calling customer (tenancy via `ICurrentUser` → `customer_profiles`); the booking is
|
||
in a **completed/closed** status (reject `cancelled`/`expired`/in-progress); **no review already exists**
|
||
for that booking (1:1). Insert as `pending_moderation`. If tag codes were passed, also write
|
||
`review_tag_links` in the same transaction (validate codes against `review_tags_master`).
|
||
- FluentValidation: `rating` in 1..5, `body` length bound.
|
||
- On create, if `rating <= min_rating_for_support_alert` (config, default 2), **call the b1
|
||
`RaiseSupportAlert`** with a `low_rating` type and the `review_id`/`booking_id` linkage (see §3.4).
|
||
- **`ModerateReviewCommand`** (`Commands/ModerateReview/`) — admin/AI transition: `publish` | `hide` |
|
||
`reject` | `unpublish`. Sets `moderation_status`, `moderation_reason` (on hide/reject), `moderated_by_id`,
|
||
`moderated_at`. **In the same transaction**: (a) recompute the nurse aggregate from source (§3.3); (b) the
|
||
audit interceptor writes the transition to `audit_logs`. After commit, trigger the b7 search-index refresh
|
||
for that nurse (§3.4). Optionally notify the review author of the outcome via `INotificationDispatcher`.
|
||
- The AI verdict (auto pre-screen) runs through `IReviewModerationService` on `SubmitReview` (§4); the
|
||
*decision authority* is still this command — a verdict can pre-set `pending_moderation` with a flag, or
|
||
auto-`published`/auto-`hidden` per config, but the human path must always be able to override.
|
||
- **`AttachReviewTagsCommand`** (`Commands/AttachReviewTags/`) — add/replace `review_tag_links` for a review
|
||
the caller owns (or admin). Enforce the unique `(review_id, review_tag_master_id)`.
|
||
- **`ListReviewsForNurseQuery`** (`Queries/ListReviewsForNurse/`) — **public**, paginated, returns
|
||
**`published` only** + the nurse aggregate (avg rating + count). `AsNoTracking()` + `.Select()` projection;
|
||
cache the aggregate read through `ICacheService` with invalidation on transition.
|
||
- **`GetReviewModerationQueueQuery`** (`Queries/GetReviewModerationQueue/`) — **admin**, paginated, filter by
|
||
`moderation_status` (default `pending_moderation`), sortable, includes any linked low-rating alert id.
|
||
- **`GetTagAggregatesQuery`** (`Queries/GetTagAggregates/`) — per-nurse tag rollup ("% punctual" = links for
|
||
that tag over published reviews of that nurse). Paginated/bounded; from published reviews only.
|
||
|
||
### 3.3 Aggregate recompute (internal domain service)
|
||
|
||
- **`RecomputeNurseRating`** — an internal application service (not an endpoint), invoked by **every**
|
||
`ModerateReviewCommand` transition *and* on `SubmitReview` only insofar as a brand-new review is
|
||
`pending_moderation` and therefore must **not** yet count. Recompute `nurse_profiles.average_rating` and
|
||
`review_count` **from the source** — i.e. `AVG(rating)`/`COUNT(*)` over the nurse's **currently
|
||
`published`** reviews — never by incremental `+delta`/`-delta`. This is the fix for inflated-rating-after-
|
||
hide drift: hiding a 1-star *lowers* the count and re-derives the average from what remains public. Do it
|
||
inside the same transaction as the status change.
|
||
|
||
### 3.4 Patient care records — commands & queries
|
||
|
||
Feature folder `Baya.Application/Features/PatientCareRecords/`.
|
||
|
||
- **`WritePatientCareRecordCommand`** (`Commands/WritePatientCareRecord/`) — a nurse authors a note for a
|
||
**patient** (optionally tagged with the `booking_id` that produced it). Encrypt `body` via
|
||
`IFieldEncryptor.Encrypt(...)` before persisting to `body_encrypted`. Guard: the calling nurse must have a
|
||
**confirmed** (or active/completed) booking for that patient — a nurse cannot write notes for a patient
|
||
they were never assigned to.
|
||
- **`GetPatientHistoryQuery`** (`Queries/GetPatientHistory/`) — patient-scoped longitudinal history,
|
||
paginated, ordered `recorded_at DESC`. Decrypt each `body_encrypted` via `IFieldEncryptor.Decrypt(...)`
|
||
**only after** the access check passes. **Strict access (§5):** the owning customer (patient's
|
||
`customer_profile`), any nurse with a **confirmed** booking for that patient, and admin — *nobody else*.
|
||
|
||
### 3.5 REST endpoints
|
||
|
||
Controllers under `Baya.Web.Api/Controllers/V1/` (`sealed`, `BaseController`, inject `ISender`,
|
||
`[controller]`/`[action]` snake_case tokens, `base.OperationResult(...)`, narrowest authorize policy, OTP/
|
||
refund-grade rate limiting not required here but keep public review-read sensibly limited):
|
||
|
||
| Verb & route | Maps to | Auth |
|
||
| --- | --- | --- |
|
||
| `POST /v1/bookings/{booking_id}/review` | `SubmitReviewCommand` | customer (owns booking) |
|
||
| `POST /v1/reviews/{id}/tags` | `AttachReviewTagsCommand` | review owner / admin |
|
||
| `PATCH /v1/reviews/{id}/status` | `ModerateReviewCommand` | admin / moderator |
|
||
| `GET /v1/nurses/{nurse_profile_id}/reviews` | `ListReviewsForNurseQuery` | public |
|
||
| `GET /v1/nurses/{nurse_profile_id}/review-tags` | `GetTagAggregatesQuery` | public |
|
||
| `GET /v1/admin/reviews/moderation-queue` | `GetReviewModerationQueueQuery` | admin |
|
||
| `POST /v1/patients/{patient_id}/care-records` | `WritePatientCareRecordCommand` | nurse (confirmed booking) |
|
||
| `GET /v1/patients/{patient_id}/care-records` | `GetPatientHistoryQuery` | owning customer / nurse w/ confirmed booking / admin |
|
||
|
||
### 3.6 Out of scope (DEFERRED — build the seam/hook, not the feature)
|
||
|
||
- Two-way (nurse-reviews-customer) double-blind reviews with timed reveal — **(DEFERRED)**, see
|
||
`product/business/11-reviews-trust-and-safety.md` (c).
|
||
- First-class `incidents` entity + ML fraud scoring — **(DEFERRED)**; manual suspension + `support_alerts`
|
||
cover it now.
|
||
- The ticket system, partner centers, and the admin **support-alert worklist console** —
|
||
**(DEFERRED → [backend-phase-15](backend-phase-15.md))**. You *raise* alerts here; b15 builds the worklist.
|
||
- `SuspendNurse` / `ResolveSupportAlert` / `FlagConcern` admin actions — **(DEFERRED → b15)** with the
|
||
support backoffice.
|
||
|
||
## 4. Mocks & seams in this phase
|
||
|
||
| Seam | Owner | Mock behaviour | Registry |
|
||
| --- | --- | --- | --- |
|
||
| **`IReviewModerationService`** (AI moderation) — **INTRODUCED here** | this phase | `Task<ModerationVerdict> ScreenAsync(string reviewText, CancellationToken)` returning a verdict (`Approve`/`Flag`/`Reject` + reason). Mock = a **keyword filter / pass-through**: clean text → `Approve` (or "needs human review" per config), banned-word hit → `Flag`. No external call. Selection by config/registration. | **add row** |
|
||
| `IFieldEncryptor` (field encryption) — **REUSE from [b0](backend-phase-0.md)** | b0 | local symmetric key; `Encrypt`/`Decrypt`. Clinical notes go through it. Do not redefine. | reuse |
|
||
| `INurseSearch` maintenance hook — **REUSE from [b7](backend-phase-7.md)** | b7 | refreshes the nurse aggregate in `nurse_search_index`. Call it after every aggregate-changing transition. | reuse |
|
||
| `support_alerts` `RaiseSupportAlert` — **REUSE from [b1](backend-phase-1.md)** | b1 | inserts an internal alert row. Call it on low ratings. | reuse |
|
||
| `INotificationDispatcher` — **REUSE from [b0](backend-phase-0.md)/[b1](backend-phase-1.md)** | b0/b1 | in-app write (no push at MVP). Optional review-outcome notice. | reuse |
|
||
|
||
Register `IReviewModerationService` (interface in `Application/Contracts/`, mock impl in Infrastructure) via
|
||
a `ServiceConfiguration/` extension — never inline in `Program.cs`. Record it in
|
||
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) with: seam, file, what's faked,
|
||
config keys (e.g. banned-word list, auto-approve toggle), how to make it real (point `ScreenAsync` at a real
|
||
text classifier/LLM endpoint without touching `ModerateReviewCommand`), status 🟡.
|
||
|
||
## 5. Critical rules you must not get wrong
|
||
|
||
- **Review eligibility — completed/closed bookings only.** Create a review **only** for a booking in the
|
||
completed/closed status, owned by the calling customer. **Never** for `cancelled`/`expired`/in-progress
|
||
bookings, non-existent bookings, or another customer's booking. (Anti-fraud, brand integrity.)
|
||
- **Enforce 1:1 — no duplicate reviews.** A unique constraint on `reviews.booking_id` is the authoritative
|
||
backstop; the handler also checks first and returns a clean `OperationResult` failure, never a raw DB
|
||
exception, on a second submit.
|
||
- **Recompute the nurse aggregate on EVERY transition, FROM SOURCE.** Publish, hide, reject, **and**
|
||
unpublish must all recompute `nurse_profiles.average_rating`/`review_count` from the nurse's **currently
|
||
`published`** reviews — `AVG`/`COUNT` over the source set, **not** an incremental `+delta`/`-delta`. This
|
||
is the explicit fix for the inflated-rating-after-hide drift: hiding a 1-star lowers the count and
|
||
re-derives the average. Do it transactionally with the status change, then refresh the b7 search index.
|
||
- **Publish gate — `pending_moderation` is NEVER public.** Reviews default to `pending_moderation` and must
|
||
never be rendered by any public/customer-facing query. `ListReviewsForNurseQuery` returns **`published`
|
||
only**; the aggregate counts **`published` only**. Filter at the query layer, not just the UI.
|
||
- **`patient_care_records` is PATIENT-scoped, not booking-scoped.** A new nurse taking over **must** read the
|
||
prior history before accepting — do not silo notes per booking. The scoping key is `patient_id`;
|
||
`booking_id` is nullable provenance only.
|
||
- **Strict clinical access + encrypted at rest.** `patient_care_records` are readable **only** by: the
|
||
owning customer (the patient's `customer_profile`), nurses with a **confirmed** booking for that patient,
|
||
and admin. Enforce in the authorization layer (not just the route policy). All clinical fields are
|
||
**encrypted via `IFieldEncryptor`** — never store, log, or project plaintext clinical content; decrypt only
|
||
after the access check passes. A nurse **without** a confirmed booking for that patient is **denied** read
|
||
and write.
|
||
- **Low-rating → `support_alerts` must fire reliably.** It is a **safety signal**. On a review at/below
|
||
`min_rating_for_support_alert` (config, default 2), raise the alert in the same flow; a failure to raise
|
||
must surface (it is not a best-effort fire-and-forget that can be silently swallowed).
|
||
- **`support_alerts` are internal-only.** They must never appear in any user-facing response or join. You
|
||
*raise* them; their worklist UI is b15.
|
||
- **Append-only audit.** Every review transition writes `audit_logs` via the interceptor — never mutate or
|
||
delete prior audit rows. The low-rating threshold is **config-driven** (read via the b1 typed accessor),
|
||
never hard-coded.
|
||
|
||
## 6. Definition of Done
|
||
|
||
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
|
||
|
||
- [ ] `reviews`, `review_tags_master`, `review_tag_links`, `patient_care_records` exist via one additive
|
||
migration with the constraints in §3.1 (unique `booking_id`, `rating` CHECK 1–5, unique
|
||
`(review_id, review_tag_master_id)`); `review_tags_master` is seeded.
|
||
- [ ] `SubmitReview`/`ModerateReview`/`AttachReviewTags`/`ListReviewsForNurse`/`GetReviewModerationQueue`/
|
||
`GetTagAggregates`/`WritePatientCareRecord`/`GetPatientHistory` are implemented as CQRS features with
|
||
validators and the §3.5 endpoints, returning the standard `OperationResult` envelope.
|
||
- [ ] Every moderation transition recomputes `nurse_profiles` **from source** and triggers the b7 search
|
||
refresh; the recompute is covered by a test that proves hide lowers both count and average.
|
||
- [ ] Low rating raises a `support_alerts` row using the b1 API and the config threshold; verified by a test.
|
||
- [ ] `patient_care_records` are encrypted at rest via `IFieldEncryptor` and gated by the strict clinical
|
||
access rule; a nurse without a confirmed booking is denied (tested).
|
||
- [ ] `IReviewModerationService` is introduced behind a DI seam with a keyword/pass-through mock and a
|
||
registry row.
|
||
- [ ] `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green including this phase's tests.
|
||
- [ ] The contract `dev/contracts/domains/reviews-records.md` is written and the `swagger.json` snapshot is
|
||
refreshed; the `server/CLAUDE.md` *Project map* notes the two new feature areas + the
|
||
`IReviewModerationService` seam.
|
||
|
||
## 7. How to test (what a human can verify after this phase)
|
||
|
||
Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger or
|
||
curl. Expected results below become the "what can be tested" section of your report.
|
||
|
||
1. **Submit on a completed booking → accepted.** As the owning customer, `POST /v1/bookings/{completed_id}/review`
|
||
with `rating: 5`. → `200`, review created with `moderation_status: pending_moderation`. It does **not**
|
||
appear in `GET /v1/nurses/{id}/reviews` yet (publish gate).
|
||
2. **Submit on a cancelled booking → rejected.** Same call against a `cancelled`/`expired` booking → an
|
||
`OperationResult` failure (not a 500). A second submit on the already-reviewed booking → failure (1:1).
|
||
3. **Moderate publish recomputes up.** `PATCH /v1/reviews/{id}/status` `publish` → review appears in the
|
||
public list; `GET /v1/nurses/{id}/reviews` aggregate `review_count` increments and `average_rating`
|
||
reflects it.
|
||
4. **Moderate hide recomputes down.** Publish a 5★ and a 1★, then `hide` the 1★ → public list drops it and
|
||
the aggregate `average_rating` rises / `review_count` decrements (re-derived from source — not stale).
|
||
5. **Low rating raises an alert.** Submit `rating: 1` → a `support_alerts` row of the low-rating type exists
|
||
(visible only on the admin/internal path, never in any user response).
|
||
6. **Write + read a care record with access control.** As a nurse **with a confirmed booking** for patient
|
||
P, `POST /v1/patients/{P}/care-records` with a clinical note → `200`; the stored column is ciphertext (not
|
||
plaintext). As the owning customer or that nurse, `GET /v1/patients/{P}/care-records` → decrypted note
|
||
returned, newest first.
|
||
7. **Unauthorized nurse denied.** As a nurse **without** any confirmed booking for patient P, both the write
|
||
and the read of P's care records → `403`/access-denied `OperationResult`.
|
||
|
||
## 8. Hand off & document (close the phase)
|
||
|
||
- **Docs to update (same change):** `server/CLAUDE.md` *Project map* — add the `Features/Reviews` and
|
||
`Features/PatientCareRecords` areas, the four new tables + their config folder, and the
|
||
`IReviewModerationService` seam (where it's registered). If you had to add the aggregate columns to
|
||
`nurse_profiles`, note it. If you discovered/decided any business rule not already in the product docs,
|
||
reflect it in [`product/business/11-reviews-trust-and-safety.md`](../../../product/business/11-reviews-trust-and-safety.md)
|
||
or [`product/data-model/10-reviews-and-records.md`](../../../product/data-model/10-reviews-and-records.md)
|
||
(no invented rules — record decisions, and regenerate the HTML view per `product/CLAUDE.md` if you touched
|
||
Markdown).
|
||
- **Contract to write:** publish **`dev/contracts/domains/reviews-records.md`** (the §3.5 routes, request/
|
||
response shapes, the `moderation_status` enum, the `review_tag` codes, the care-record access-rule matrix,
|
||
status codes, examples) per [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md),
|
||
and refresh the `swagger.json` snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md)
|
||
so [frontend-phase-13-b14](../frontend/frontend-phase-13-b14.md) can derive its types (it does not guess shapes).
|
||
- **Handoff & report:** write `shared-working-context/backend/handoff/after-backend-phase-14.md` (reviews +
|
||
care-records endpoints are live; what f13 can now build; what's mocked — the AI moderation seam; the
|
||
publish-gate and clinical-access rules the frontend must respect), append your phase summary to
|
||
`shared-working-context/backend/STATUS.md`, write `reports/backend-phase-14-report.md` (what was built,
|
||
what is now testable and exactly how — the §7 steps — what is mocked + how to make it real, contracts
|
||
produced, follow-ups for b15), and update
|
||
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) with the
|
||
`IReviewModerationService` row → 🟡.
|
||
- **Memory:** save a `project`-type memory note for the non-obvious decisions here — the **recompute-from-
|
||
source (not delta)** rule and where it's invoked, the **patient-scoped (not booking-scoped)** care-record
|
||
access matrix, and the `IReviewModerationService` seam selection — with a one-line `MEMORY.md` pointer.
|