Session Reviews API
A mentee rates the session they sat through; the rating surfaces on the mentor's public profile and recomputes that mentor's headline figure. Course reviews are a separate system on a separate table ā see course-api.md §2.2.
- Base URL:
/api/v1 - Auth: every endpoint requires
Authorization: Bearer {token}(Sanctum) and a completed 2FA challenge where the account has 2FA enabled. - Content type:
application/json - Field convention:
snake_casethroughout, including on the read side.
Response envelope (all endpoints):
// Success // Error
{ {
"status": "success", "status": "error",
"message": "ā¦", "message": "ā¦",
"data": { } "data": { }
} }
The three rules that shape everything
1. A review belongs to a booking, not to a mentor.
The unique key is on session_reviews.booking_id. That is what makes "one review per session" a
database constraint rather than a rule a service has to police, and it is what gives every review
card its Video appointment Ā· Dec 16, 2024 meta line ā the session type is read through the
booking. A mentee who sat three sessions with the same mentor may write three reviews; a mentee who
sat one may write one, and revise it.
2. Posting twice revises. There is no PUT.
POST /mentee/bookings/{id}/review is an upsert. The first post returns 201, every later one
returns 200. The mentor is notified on the first only ā an edit is the same mentee revising the
same opinion, and notifying again would let one person ring the mentor's bell as often as they liked.
3. Only a completed session can be reviewed, and only by the mentee who booked it.
A review written before the session is a review of nothing. Somebody else's booking is a 404, not
a 403, so this route cannot be used to probe which booking ids exist or which sessions have been
rated.
What a review write changes
Every write recomputes the mentor's denormalised rating summary inside the same transaction as the review, so the bars on the profile can never disagree with the rows underneath them.
Column on mentor_profiles |
Meaning |
|---|---|
average_rating |
DECIMAL(3,2), all-time mean of every session review. 0.00 when there are none. |
rating_count |
All-time count across every rating. |
rating_breakdown |
JSON, always all five keys "5"ā¦"1", zeros included. |
average_ratingwas fabricated in every seeded environment before this module.DemoDataSeederwroterandomFloat(2, 4.0, 5.0)into it andQaTestAccountsSeedera flat4.80. Nothing inapp/ever wrote the column, but the seeders did ā so oncerating_countlanded beside it, seeded mentors began serving"average_rating": "4.13"next to"rating_count": 0, a rating from nobody. Migration2026_09_10_100003_reconcile_mentor_rating_summaryrecomputes all three columns from the reviews that actually exist, and both seeders have stopped writing the column. Run migrations on any environment seeded before that date.
Those columns are read by four surfaces that previously served a hard-coded null rather than
report a fabricated 0.00:
| Surface | Field |
|---|---|
GET /mentee/dashboard spotlight banner |
average_rating, total_reviews |
GET /mentor/analytics |
stats.average_rating ā see the note below |
GET /chat/threads/{id} participant block |
reviews.average_rating, reviews.count |
Mentor search filter[rating] |
filters on average_rating |
Analytics is the exception, deliberately. stats.average_rating is measured over the reviews
written inside the selected window, not off mentor_profiles.average_rating. Only a windowed
average has a previous window to compare against; a change percentage against a lifetime number that
barely moves is a number that says nothing.
The chat participant block folds both review kinds into one figure ā course reviews across the mentor's catalogue plus their session reviews ā as one count and one weighted average. Sum and count, divided once at the end: averaging two averages would weight a mentor's single course review as heavily as their two hundred session reviews.
Endpoints
1. List a profile's reviews, with the summary
GET /api/v1/user/{username}/reviews
The rows and the rating summary in one call, so the reviews tab needs one request rather than two.
{username} is the profile's handle, the same one GET /user/{username} takes.
Query parameters
| Param | Values | Default |
|---|---|---|
rating |
1ā5. Omit for all ratings |
omitted |
sort |
most_relevant Ā· newest Ā· highest_rating Ā· lowest_rating |
most_relevant |
page |
int ā„ 1 | 1 |
per_page |
int, 1ā50 | 10 |
An invalid rating or sort is a 422 with field errors, not a silent fallback to the default.
A filter that quietly does nothing is a filter nobody notices is broken.
Sort semantics. Every option ends its ordering on id, so a page boundary cannot drop or repeat
a row when two reviews tie on the sort key ā with ties on every one of five ratings, that is the
common case, not the edge. most_relevant is most-helpful-first, then newest, so an unvoted review
is not buried under an old one nobody voted on either.
Response 200
{
"status": "success",
"message": "Reviews retrieved successfully.",
"data": {
"summary": {
"average_rating": 4.9,
"rating_count": 1500,
"rating_breakdown": { "5": 1000, "4": 350, "3": 125, "2": 25, "1": 0 }
},
"reviews": {
"data": [
{
"id": "9b1fā¦",
"rating": 5,
"body": "Genuinely useful hour.",
"created_at": "2026-09-08T10:24:00+00:00",
"updated_at": "2026-09-08T10:24:00+00:00",
"session_type": "Video appointment",
"booking_id": "9b1eā¦",
"is_mine": false,
"helpful_yes_count": 12,
"helpful_no_count": 1,
"my_helpful_vote": null,
"author": {
"id": "9b1dā¦",
"name": "Ahmed K.",
"username": "ahmedk",
"avatar_url": "https://ā¦/avatars/ahmedk.jpg"
}
}
],
"links": { "first": "ā¦", "last": "ā¦", "prev": null, "next": "ā¦" },
"meta": { "current_page": 1, "last_page": 150, "per_page": 10, "total": 1500 }
}
}
}
Field notes
| Field | Notes |
|---|---|
summary.average_rating |
Float, one decimal. 0, never null, when there are no reviews ā one value for "no rating yet" rather than two the client has to test for. A whole-number average arrives as 4, not 4.0: JSON has no fractional part left to encode. Do not assume a decimal point. |
summary.rating_count |
All time, across every rating, and never narrowed by the rating filter. |
summary.rating_breakdown |
A JSON object, always five keys, ordered "5" ā "1". Zeros are present, never omitted. |
session_type |
The session type's title. null when the type behind the booking has been deleted ā the card then renders a bare date. |
is_mine |
Present on every row, never conditional. The client hides the helpful buttons and the report flag by it. |
my_helpful_vote |
true, false, or null when the viewer has not voted. Annotated onto the page in the same query, so a page of reviews costs one query and not one per card. |
helpful_*_count |
Denormalised tallies, recomputed on every vote. |
author.avatar_url |
null when unset ā fall back to initials. |
The rating filter narrows the rows and nothing else. Ask for rating=1 against a mentor with
1,500 reviews and you get the full-scope summary beside a one-row list: the bars stay up at full
height while the list below shows one star's worth of rows, or its empty state.
A user who has never mentored gets an empty tab, not a 404. The profile resolved; it simply has
no sessions behind it. rating_count is 0, the breakdown is five zeros, reviews.meta.total is 0.
| Code | When |
|---|---|
200 |
Including the empty cases above |
401 |
No token |
404 |
Unknown username |
422 |
rating outside 1ā5, unknown sort, per_page above 50 |
2. Write or revise a review
POST /api/v1/mentee/bookings/{bookingId}/review
{ "rating": 5, "comment": "Genuinely useful hour." }
| Field | Rules |
|---|---|
rating |
required, integer 1ā5 |
comment |
optional, string, max 5000 |
body |
accepted as an alias for comment ā the read side calls it body |
Response 201 on the first write, 200 on every revision. data.review is one review object
in exactly the shape §1 returns.
{
"status": "success",
"message": "Review saved successfully.",
"data": { "review": { "id": "9b1fā¦", "rating": 5, "ā¦": "ā¦", "is_mine": true } }
}
| Code | When |
|---|---|
201 |
First review for this booking |
200 |
Revision of an existing one |
403 |
The session is not completed yet |
404 |
Unknown booking, or a booking that is not the caller's |
422 |
rating missing or outside 1ā5 |
Side effects of a first write: the mentor receives a session.review_received notification
(database channel, Low priority, gated by the User Feedback switch in
settings-api.md), a session.reviewed row lands in the mentee's activity log, and
the mentor's rating summary is recomputed. A revision recomputes the summary and does nothing else.
3. Read back the caller's own review
GET /api/v1/mentee/bookings/{bookingId}/review
Prefills the form for editing.
{
"status": "success",
"message": "Review retrieved successfully.",
"data": { "review": null, "can_review": true }
}
review is null when the caller has not written one ā that is not an error. can_review is true
only for a completed session that has not been reviewed yet.
| Code | When |
|---|---|
200 |
Including review: null |
404 |
Unknown booking, or not the caller's |
You usually do not need this call. GET /mentee/bookings and GET /mentee/bookings/{id} already
carry canBeReviewed and an embedded review on every card, eager-loaded ā enough to place the
Review button and prefill the dialog without a second round trip. This endpoint exists for a form
opened from a deep link, where no booking payload is in hand.
ā ļø Those two booking fields are
camelCase, becauseSessionBookingResourceiscamelCasethroughout and predates this module. Everything under/reviewsand/user/{username}/reviewsissnake_case. See reviews-api-response.md §E.
4. Was this helpful? ā vote
POST /api/v1/reviews/{reviewId}/helpful { "helpful": true }
POST /api/v1/reviews/{reviewId}/helpful { "helpful": false }
DELETE /api/v1/reviews/{reviewId}/helpful
Three states, not two: yes, no, and none. Tapping the active button again clears the vote, which
is the DELETE. The row's absence is the third state ā nothing here ever writes a null, because a
nullable boolean would make "has not voted" and "voted neither" the same value, and the toggle seeds
itself from exactly that distinction.
Response 200 ā identical from all three calls, so the client can patch its cached review row by
id without refetching the list:
{
"status": "success",
"message": "Vote saved.",
"data": {
"review_id": "9b1fā¦",
"my_helpful_vote": true,
"helpful_yes_count": 13,
"helpful_no_count": 1
}
}
DELETE answers with the same shape and "message": "Vote cleared.".
| Code | When |
|---|---|
200 |
Vote set, flipped, or cleared |
401 |
No token |
404 |
Unknown review |
422 |
Voting on your own review ā the client hides the buttons there, so this is a guard, not a flow |
Clearing a vote you never cast is a no-op, not an error. The client fires DELETE on a second
tap and cannot know whether an optimistic update raced it.
Flipping replaces. One vote per viewer per review, enforced by a unique key. Tallies are recomputed from the votes rather than incremented: an increment has to know which way the previous vote pointed, and gets it wrong the moment two tabs race.
5. Report a review
POST /api/v1/reviews/{reviewId}/report
{ "reason": "spam", "details": "optional context" }
| Field | Rules |
|---|---|
reason |
required, one of spam Ā· harassment Ā· hate_speech Ā· violence Ā· nudity Ā· misinformation Ā· other |
details |
required when reason is other, otherwise optional. String, max 2000 |
Filed into the same feed_reports table as posts, comments and profiles ā a review is just another
polymorphic reportable, so a moderator sees it in the same queue with the same reason vocabulary, and
the one-report-per-user-per-target rule comes along for free.
Response 201
{ "status": "success", "message": "Report submitted successfully.", "data": { "report_id": "9b20ā¦" } }
| Code | When |
|---|---|
201 |
Report filed |
404 |
Unknown review |
409 |
You have already reported this review |
422 |
Unknown reason; details missing on other; reporting your own review |
Route reference
GET api/v1/user/{user:username}/reviews auth.user.reviews
GET api/v1/mentee/bookings/{id}/review mentee.bookings.review.show
POST api/v1/mentee/bookings/{id}/review mentee.bookings.review.store
POST api/v1/reviews/{review}/helpful reviews.helpful.store
DELETE api/v1/reviews/{review}/helpful reviews.helpful.destroy
POST api/v1/reviews/{review}/report reviews.report
Schema
session_reviews
| Column | Type | Notes |
|---|---|---|
id |
UUID | PK |
booking_id |
UUID | FK ā session_bookings, UNIQUE ā one review per session |
mentor_id |
UUID | FK ā mentor_profiles. Denormalised off the booking so a profile's reviews are one indexed read rather than a join |
user_id |
UUID | The author. Named user_id, not mentee_id, because that is the column every reportable in this system hangs off ā it is what lets the shared report service refuse a self-report without a special case |
rating |
TINYINT | 1ā5 |
body |
TEXT | Nullable |
helpful_yes_count |
INT | Denormalised tally |
helpful_no_count |
INT | Denormalised tally |
Indexes: (mentor_id, created_at) for the listing, (mentor_id, rating) for the filter and the
summary, (mentor_id, helpful_yes_count) for sort=most_relevant.
session_review_helpful_votes
| Column | Type | Notes |
|---|---|---|
id |
UUID | PK |
review_id |
UUID | FK ā session_reviews |
user_id |
UUID | FK ā users |
is_helpful |
BOOLEAN | Never null ā the absence of the row is "no vote" |
Unique on (review_id, user_id).
Known gaps
A hard-deleted reviewer takes their reviews with them. session_reviews.user_id cascades on
delete, so a deleted account's reviews vanish and the mentor's summary is not recomputed. This is
inert today ā nothing in the application hard-deletes a user, and User has no SoftDeletes ā
but if account deletion ships, that FK wants to become nullOnDelete alongside a stored author name,
and the deletion path needs a recalculate() call. The read side already tolerates a null author.
There is no delete endpoint. A mentee can revise a review to a different rating but cannot remove
it. Course reviews have DELETE /courses/{course}/reviews/me; session reviews deliberately do not,
because the review is evidence attached to a delivered, paid session. Say the word if that should
change.
Reported reviews are captured, not triaged. GET /admin/user-reports groups by user target
only, so a review report lands in the table and is queryable, but does not yet surface in the admin
queue. That is admin-dashboard work ā see admin-brd.md §7.