27 KiB
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:
- Reviews & ratings — public, social-proof, moderated, aggregate-driving.
- 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 built
bookingswith 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 setdispute_window_ends_at = completed_at + dispute_window_hourson completion. Read these statuses and relations; do not re-model booking lifecycle. - Profiles & patients — backend-phase-3 built
customer_profiles,nurse_profiles(including the denormalized aggregate rating/count fields you recompute here), andpatients(with customer tenancy). The aggregate columns onnurse_profilesare owned by the nurse domain but written by this phase on every review transition. - Platform signals & config — backend-phase-1 built
support_alerts(the internal-only staff worklist +RaiseSupportAlertAPI),platform_configs(the typed cached accessor — includingmin_rating_for_support_alert),audit_logs(append-only, written by the SaveChanges interceptor on sensitive entities), and the in-appnotificationswrite. Reuse all of these — you raise alerts, you do not define the table. - Search aggregates — backend-phase-7 built
nurse_search_indexbehind theINurseSearchseam, 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 introduced
IFieldEncryptor(the field encryptor you use for clinical notes),ICacheService,IDateTimeProvider, andINotificationDispatcher. ReuseIFieldEncryptorforpatient_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 existingsupport_alertsraise 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.mdand../_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.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), andpatient_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
bookingsstatus enum anddispute_window_ends_atfrom 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 reuse — backend-phase-1's handoff and
dev/contracts/domains/platform-signals.md(or equivalent) for theRaiseSupportAlertsignature, thesupport_alertsshape, and the typed config accessor formin_rating_for_support_alert. - Search refresh you trigger — backend-phase-7's handoff for the
INurseSearchmaintenance hook that refreshes a nurse's aggregate rating/count innurse_search_index. - Code to mirror (existing patterns): an existing feature folder under
Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/(requestrecord+internal sealedhandler +OperationResult), anIEntityTypeConfiguration<T>underPersistence/Configuration/<Area>Config/, a controller underBaya.Web.Api/Controllers/V1/(sealed,BaseController,ISender,base.OperationResult(...)), and how prior phases callIFieldEncryptorfor encrypted columns (b3 IBAN, b9 care instructions). - Contract conventions:
../../contracts/conventions/api-conventions.md(envelope, routes, status codes) andmoney-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, CHECKrating BETWEEN 1 AND 5),body(NVARCHAR, nullable free text),moderation_status(enum:pending_moderation|published|hidden|rejected; defaultpending_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 onmoderation_statusfor the moderation queue. - Soft-delete query filter (
!IsDeleted).
- Columns:
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).
- Columns:
review_tag_links— N:N joinreviews↔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).
- Columns:
patient_care_records— nurse-authored, encrypted, patient-scoped clinical notes.- Columns:
id(BIGINT PK),patient_id(FK →patients, the scoping key — notbooking_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 theIFieldEncryptor-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.
- Columns:
Aggregate columns are not yours to add.
nurse_profilesalready carries the denormalizedaverage_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 submitsrating(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 viaICurrentUser→customer_profiles); the booking is in a completed/closed status (rejectcancelled/expired/in-progress); no review already exists for that booking (1:1). Insert aspending_moderation. If tag codes were passed, also writereview_tag_linksin the same transaction (validate codes againstreview_tags_master). - FluentValidation:
ratingin 1..5,bodylength bound. - On create, if
rating <= min_rating_for_support_alert(config, default 2), call the b1RaiseSupportAlertwith alow_ratingtype and thereview_id/booking_idlinkage (see §3.4).
- Guards (return
ModerateReviewCommand(Commands/ModerateReview/) — admin/AI transition:publish|hide|reject|unpublish. Setsmoderation_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 toaudit_logs. After commit, trigger the b7 search-index refresh for that nurse (§3.4). Optionally notify the review author of the outcome viaINotificationDispatcher.- The AI verdict (auto pre-screen) runs through
IReviewModerationServiceonSubmitReview(§4); the decision authority is still this command — a verdict can pre-setpending_moderationwith a flag, or auto-published/auto-hiddenper config, but the human path must always be able to override.
- The AI verdict (auto pre-screen) runs through
AttachReviewTagsCommand(Commands/AttachReviewTags/) — add/replacereview_tag_linksfor a review the caller owns (or admin). Enforce the unique(review_id, review_tag_master_id).ListReviewsForNurseQuery(Queries/ListReviewsForNurse/) — public, paginated, returnspublishedonly + the nurse aggregate (avg rating + count).AsNoTracking()+.Select()projection; cache the aggregate read throughICacheServicewith invalidation on transition.GetReviewModerationQueueQuery(Queries/GetReviewModerationQueue/) — admin, paginated, filter bymoderation_status(defaultpending_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 everyModerateReviewCommandtransition and onSubmitReviewonly insofar as a brand-new review ispending_moderationand therefore must not yet count. Recomputenurse_profiles.average_ratingandreview_countfrom the source — i.e.AVG(rating)/COUNT(*)over the nurse's currentlypublishedreviews — 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 thebooking_idthat produced it). EncryptbodyviaIFieldEncryptor.Encrypt(...)before persisting tobody_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, orderedrecorded_at DESC. Decrypt eachbody_encryptedviaIFieldEncryptor.Decrypt(...)only after the access check passes. Strict access (§5): the owning customer (patient'scustomer_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
incidentsentity + ML fraud scoring — (DEFERRED); manual suspension +support_alertscover 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/FlagConcernadmin 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 RaiseSupportAlert — REUSE from b1 |
b1 | inserts an internal alert row. Call it on low ratings. | reuse |
INotificationDispatcher — REUSE 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_idis the authoritative backstop; the handler also checks first and returns a cleanOperationResultfailure, 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_countfrom the nurse's currentlypublishedreviews —AVG/COUNTover 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_moderationis NEVER public. Reviews default topending_moderationand must never be rendered by any public/customer-facing query.ListReviewsForNurseQueryreturnspublishedonly; the aggregate countspublishedonly. Filter at the query layer, not just the UI. patient_care_recordsis 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 ispatient_id;booking_idis nullable provenance only.- Strict clinical access + encrypted at rest.
patient_care_recordsare readable only by: the owning customer (the patient'scustomer_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 viaIFieldEncryptor— 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_alertsmust fire reliably. It is a safety signal. On a review at/belowmin_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_alertsare 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_logsvia 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_recordsexist via one additive migration with the constraints in §3.1 (uniquebooking_id,ratingCHECK 1–5, unique(review_id, review_tag_master_id));review_tags_masteris seeded.SubmitReview/ModerateReview/AttachReviewTags/ListReviewsForNurse/GetReviewModerationQueue/GetTagAggregates/WritePatientCareRecord/GetPatientHistoryare implemented as CQRS features with validators and the §3.5 endpoints, returning the standardOperationResultenvelope.- Every moderation transition recomputes
nurse_profilesfrom 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_alertsrow using the b1 API and the config threshold; verified by a test. patient_care_recordsare encrypted at rest viaIFieldEncryptorand gated by the strict clinical access rule; a nurse without a confirmed booking is denied (tested).IReviewModerationServiceis introduced behind a DI seam with a keyword/pass-through mock and a registry row.dotnet build Baya.slnzero new warnings;dotnet test Baya.slngreen including this phase's tests.- The contract
dev/contracts/domains/reviews-records.mdis written and theswagger.jsonsnapshot is refreshed; theserver/CLAUDE.mdProject map notes the two new feature areas + theIReviewModerationServiceseam.
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.
- Submit on a completed booking → accepted. As the owning customer,
POST /v1/bookings/{completed_id}/reviewwithrating: 5. →200, review created withmoderation_status: pending_moderation. It does not appear inGET /v1/nurses/{id}/reviewsyet (publish gate). - Submit on a cancelled booking → rejected. Same call against a
cancelled/expiredbooking → anOperationResultfailure (not a 500). A second submit on the already-reviewed booking → failure (1:1). - Moderate publish recomputes up.
PATCH /v1/reviews/{id}/statuspublish→ review appears in the public list;GET /v1/nurses/{id}/reviewsaggregatereview_countincrements andaverage_ratingreflects it. - Moderate hide recomputes down. Publish a 5★ and a 1★, then
hidethe 1★ → public list drops it and the aggregateaverage_ratingrises /review_countdecrements (re-derived from source — not stale). - Low rating raises an alert. Submit
rating: 1→ asupport_alertsrow of the low-rating type exists (visible only on the admin/internal path, never in any user response). - Write + read a care record with access control. As a nurse with a confirmed booking for patient
P,
POST /v1/patients/{P}/care-recordswith 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. - 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-deniedOperationResult.
8. Hand off & document (close the phase)
- Docs to update (same change):
server/CLAUDE.mdProject map — add theFeatures/ReviewsandFeatures/PatientCareRecordsareas, the four new tables + their config folder, and theIReviewModerationServiceseam (where it's registered). If you had to add the aggregate columns tonurse_profiles, note it. If you discovered/decided any business rule not already in the product docs, reflect it inproduct/business/11-reviews-trust-and-safety.mdorproduct/data-model/10-reviews-and-records.md(no invented rules — record decisions, and regenerate the HTML view perproduct/CLAUDE.mdif you touched Markdown). - Contract to write: publish
dev/contracts/domains/reviews-records.md(the §3.5 routes, request/ response shapes, themoderation_statusenum, thereview_tagcodes, the care-record access-rule matrix, status codes, examples) per../../contracts/conventions/api-conventions.md, and refresh theswagger.jsonsnapshot per../../contracts/openapi/README.mdso 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 toshared-working-context/backend/STATUS.md, writereports/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 updatereports/mocks-registry.mdwith theIReviewModerationServicerow → 🟡. - 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 theIReviewModerationServiceseam selection — with a one-lineMEMORY.mdpointer.