MeetyyAPI
Documentation / API Reference / Session Reviews

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_case throughout, 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_rating was fabricated in every seeded environment before this module. DemoDataSeeder wrote randomFloat(2, 4.0, 5.0) into it and QaTestAccountsSeeder a flat 4.80. Nothing in app/ ever wrote the column, but the seeders did — so once rating_count landed beside it, seeded mentors began serving "average_rating": "4.13" next to "rating_count": 0, a rating from nobody. Migration 2026_09_10_100003_reconcile_mentor_rating_summary recomputes 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, because SessionBookingResource is camelCase throughout and predates this module. Everything under /reviews and /user/{username}/reviews is snake_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.