9.5 KiB
Contract — Reviews & Patient Care Records (backend phase b14)
The trust-and-continuity surface: a customer leaves one moderated review per completed booking; an admin/moderator transitions it (recomputing the nurse's public rating from source on every transition); the public reads only ever see published reviews + the aggregate; and nurses author encrypted, patient-scoped clinical notes readable only under a strict clinical-access rule. Assumes
../conventions/api-conventions.md+../conventions/money-and-types.md. Machine schema:../openapi/swagger.v1.json.
Status: live as of backend-phase-b14 · Frontend consumer: frontend-phase-f13-b14
There is no money in this domain. Ratings are integers 1–5; the aggregate averageRating is a decimal
(2-dp, e.g. 4.5). Timestamps are ISO-8601. List query params are camelCase (page, pageSize,
status). The response envelope is the standard { data, … }; the shapes below are the data.
Enums used
moderationStatus:pending_moderation|published|hidden|rejected. A review is bornpending_moderationand is never public / never counted untilpublished. Onlypublishedreviews are returned by any public read and onlypublishedreviews feed the aggregate.- Moderation action (the
PATCHbody):publish|hide|reject|unpublish.hide/rejectrequire areason;unpublishreturns a published review topending_moderation. - Review tag codes (seeded vocabulary):
punctual|professional|clean|kind|communicative.
Reviews
POST api/v1/bookings/{bookingId}/review
- Purpose: The customer submits the one review for a completed booking.
- Auth: authenticated customer who owns the booking (tenancy enforced in the handler).
- Path params:
bookingId(long). - Request body:
{ "rating": 5, "body": "great care", "tagCodes": ["punctual","kind"] }—rating1–5 required;bodyoptional (≤ 2000);tagCodesoptional (validated against the active vocabulary). - Success
200(data):SubmitReviewResult—{ id, moderationStatus, lowRatingAlertRaised }. The status ispending_moderationby default (the AI pre-screen keeps clean text pending; a banned-word hit auto-hides). - Failure cases:
400rating out of 1–5 / unknown tag code;401unauthenticated;403caller is not a customer;404booking not found or not owned (a cross-tenant booking is a not-found, never a leak); a plain failure when the booking is not completed/closed;409the booking is already reviewed (1:1). - Notes: A rating ≤
min_rating_for_support_alert(config, default 2) raises an internallow_ratingsupport_alert(never surfaced on any user response). The new review does not appear in the public list until published.
POST api/v1/reviews/{reviewId}/tags
- Purpose: Replace a review's standardized tags with exactly the requested set.
- Auth: the review's author or a moderator (admin/super_admin/moderation) — enforced in the handler.
- Path params:
reviewId(long). Request body:{ "tagCodes": ["punctual","professional"] }. - Success
200(data):ReviewTagsResult—{ reviewId, tagCodes }. - Failure cases:
400unknown tag code;401;403not the author and not a moderator;404review not found. TheUNIQUE(reviewId, tagCode)forbids a duplicate tag (the set is de-duplicated server-side).
PATCH api/v1/reviews/{reviewId}/status
- Purpose: Admin/moderator moderation transition (the human decision authority; always overrides the AI).
- Auth: admin / moderator (dynamic-permission policy).
- Path params:
reviewId(long). Request body:{ "action": "publish", "reason": null }—hide/rejectrequire a non-emptyreason(≤ 500). - Success
200(data):ModerateReviewResult—{ id, moderationStatus, averageRating, totalReviews }(the recomputed-from-source nurse aggregate). - Failure cases:
400unknown action / missing reason on hide|reject;401;403non-admin;404review not found. - Notes: Every transition recomputes
nurse_profiles.averageRating/totalReviewsfrom the nurse's currently-publishedreviews (not an incremental delta) and refreshes the search index — in the same transaction as the status change — so hiding a low rating lowers the count and re-derives the average.
GET api/v1/nurses/{nurseProfileId}/reviews — public
- Purpose: The public reviews for a nurse: the rating aggregate + a page of published reviews.
- Auth: anonymous. Path params:
nurseProfileId(long). Query:page(default 1),pageSize(default 20, max 100). - Success
200(data):NurseReviewsResult—{ aggregate: { averageRating, publishedCount }, reviews: PagedResult<ReviewListItemDto> }.ReviewListItemDto={ id, rating, body, tagCodes[], createdAt }— never carries moderation internals. An unknown nurse returns a zero aggregate + empty page (200). - Notes: The publish gate is enforced at the query layer — a
pending_moderation/hidden/rejectedreview is never returned and never counted. The aggregate is cached and invalidated on every transition.
GET api/v1/nurses/{nurseProfileId}/review_tags — public
- Purpose: The per-nurse tag rollup ("% punctual") over published reviews.
- Auth: anonymous. Path params:
nurseProfileId(long). - Success
200(data):NurseTagAggregatesResult—{ publishedReviewCount, tags: TagAggregateDto[] }whereTagAggregateDto={ code, labelFa, labelEn, count, percentage }(percentage of published reviews, 1-dp). The active seeded vocabulary is always returned (zero counts for a nurse with no published reviews).
GET api/v1/admin/reviews/moderation_queue — admin
- Purpose: The moderation worklist.
- Auth: admin / moderator. Query:
status(defaultpending_moderation; anymoderationStatus),page,pageSize. - Success
200(data):PagedResult<ModerationQueueItemDto>—{ id, bookingId, nurseProfileId, customerProfileId, rating, body, moderationStatus, moderationReason, lowRatingAlertId, createdAt }. ThelowRatingAlertIdis the linked internal alert (id only — support alerts stay internal). - Failure cases:
400unknown status;401;403non-admin.
Patient care records
Clinical bodies are encrypted at rest and returned decrypted only after the access check passes. The access rule is enforced in the handler, not just the route policy.
Access matrix (both endpoints):
| Caller | Write | Read |
|---|---|---|
| Nurse with a confirmed (or in-progress/completed/disputed/closed) booking for the patient | ✅ | ✅ |
| Nurse without such a booking | ❌ 403 |
❌ 403 |
| The patient's owning customer | ❌ (only nurses author) | ✅ |
| Admin / super_admin | ❌ (only nurses author) | ✅ |
| Anyone else | ❌ | ❌ 403 |
POST api/v1/patients/{patientId}/care_records
- Purpose: A nurse authors a clinical note for a patient (patient-scoped; the note is encrypted before persist).
- Auth: authenticated nurse with a qualifying booking for the patient.
- Path params:
patientId(long). Request body:{ "bookingId": 123, "body": "…" }—bookingIdoptional provenance (which visit produced the note);bodyrequired (≤ 8000). - Success
200(data):WriteCareRecordResult—{ id, patientId, recordedAt }. - Failure cases:
401;403caller is not a nurse or has no qualifying booking for the patient;404patient not found;400empty body.
GET api/v1/patients/{patientId}/care_records
- Purpose: The patient-scoped longitudinal history, newest first.
- Auth: owning customer / nurse with a qualifying booking / admin (see the matrix).
- Path params:
patientId(long). Query:page,pageSize. - Success
200(data):PagedResult<CareRecordDto>—CareRecordDto={ id, patientId, bookingId, nurseProfileId, nurseName, body, recordedAt }(bodydecrypted). Ordered byrecordedAt DESC. - Failure cases:
401;403no clinical access;404patient not found. - Notes: The record is patient-scoped, not booking-scoped — a new nurse taking over reads the whole history (not just their own booking's notes).
Refinement phase 3 additions (REQ-026/027)
GET api/v1/bookings/{bookingId}/review_eligibility→{ canReview, reason?: not_completed|already_reviewed|not_owner|not_found }.GET api/v1/bookings/{bookingId}/my_review→{ moderationStatus: pending_moderation|published|hidden|rejected|none, rating?, body?, tagCodes[], createdAt? }. Masked-author omission on the public list is intentional (privacy).- Family-owned care plan (new entity
usr.PatientCarePlans):GET/PUT api/v1/patients/{patientId}/care_record→{ patientId, medications:[{id,name,dosage?,frequency,timingNote?}], routine:[{id,label,timeOfDay?,note?}], tasks:[{id,label,done}] }. Read = owner/nurse-with-booking/admin; write = owning customer only. GET api/v1/patients/{patientId}/record_access→{ canView, canEdit, canAppendNote, deniedReason? }(always 200; non-leakingnot_found/not_authorized).- Structured
taskResults([{ label, done }]) added to the visit-note write body + the history DTO.