Files
baya-monorepo/dev/phases/backend/backend-phase-14.md
T
2026-06-28 21:59:59 +03:30

27 KiB
Raw Blame History

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 (completed bookings + dispute window), backend-phase-3 (profiles/patients), backend-phase-1 (support_alerts, platform_configs, audit_logs, notifications), backend-phase-7 (search aggregates) · Unlocks: the reviews UI (frontend-phase-13-b14) Before you start, read ../_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 windowbackend-phase-9 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 & patientsbackend-phase-3 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 & configbackend-phase-1 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 aggregatesbackend-phase-7 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 seamsbackend-phase-0 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. 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 and ../_shared/backend-conventions-checklist.md.
  • Product — business rules (source of truth): 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.mdreviews (rating 15 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'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 reusebackend-phase-1'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 triggerbackend-phase-7'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 (envelope, routes, status codes) and 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 reviewsreview_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 (15) + 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 ICurrentUsercustomer_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). 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 b0 local symmetric key; Encrypt/Decrypt. Clinical notes go through it. Do not redefine. reuse
INurseSearch maintenance hook — REUSE from b7 b7 refreshes the nurse aggregate in nurse_search_index. Call it after every aggregate-changing transition. reuse
support_alerts RaiseSupportAlertREUSE from b1 b1 inserts an internal alert row. Call it on low ratings. reuse
INotificationDispatcherREUSE from b0/b1 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 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, 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 15, 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 or 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, and refresh the swagger.json snapshot per ../../contracts/openapi/README.md so frontend-phase-13-b14 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 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.