MeetyyAPI
Documentation / API Reference / Session Reviews โ€” response

Profile Reviews โ€” response

Reply to profile-reviews-api-request.md. Read ยงA first โ€” ยง4 said "no change needed" for a write path that did not exist. It was the largest piece of this build.

  • Base URL: /api/v1 ยท Auth: Sanctum bearer token
  • Full endpoint reference: reviews-api.md
Marker Meaning
โœ… Shipped this round
โ— A deviation from your spec, or a client action needed
๐Ÿ’ฌ An answer to an open question, not a build
โณ Not built โ€” see the reason
ยง Your ask Status
ยง1 GET /user/{username}/reviews โ€” list + summary โœ… Shipped โ€” auth differs, ยงB
ยง2 POST/DELETE /reviews/{id}/helpful โœ… Shipped as specified
ยง3 POST /reviews/{id}/report โœ… Shipped โ€” returns 201, ยงD
ยง4 "Existing write path โ€” no change needed" โ— It did not exist. Built, ยงA
โ€” Open question on the caption scope ๐Ÿ’ฌ All time. ยงC

Everything in your document is now built and tested โ€” 20 feature cases in tests/Feature/Review/SessionReviewTest.php, full suite green.


A. ยง4 was not an existing write path โ—

"Reviews are created by POST /mentee/bookings/{bookingId}/review ({ rating, comment }, one review per booking, mentee only, via useSubmitReview). Nothing new is required here, noted only so the list source is clear."

There was no such route, no service, no model and no table. useSubmitReview has been posting into a 404. The BRD does not define a session-review table either โ€” ยง13 has no such entity, and mentor_profiles.average_rating is documented as "recalculated after each review" for a system nobody had built.

So ยง1 was not "the read side of an existing write". The write side was the foundation, and it shipped with the exact contract you described:

POST /api/v1/mentee/bookings/{bookingId}/review   { "rating": 5, "comment": "โ€ฆ" }

201 on the first write, 200 on a revision, one review per booking enforced by a unique key. body is accepted as an alias for comment, so both spellings work. GET on the same path prefills the form.

Action for you: none, if useSubmitReview already posts that shape. It now has something behind it. Worth confirming it reads data.review rather than assuming an empty body.


B. Auth: /user/{username}/reviews is not public โ—

"Auth: public read (same as the profile itself)."

Those two clauses contradict each other in this API. GET /user/{username} is not public โ€” it sits behind auth:sanctum + 2fa.completed:

GET api/v1/user/{user:username}              auth.user.show        [auth:sanctum, 2fa.completed]
GET api/v1/user/{user:username}/courses      auth.user.courses     [auth:sanctum, 2fa.completed]
GET api/v1/user/{user:username}/communities  auth.user.communities [auth:sanctum, 2fa.completed]

We honoured "same as the profile itself" literally and put the reviews tab in the same group with the same guard. A guest hitting the tab gets a 401, which your apiClient already turns into a sign-in redirect โ€” the same thing that happens one call earlier when it fetches the profile.

One consequence in your favour: my_helpful_vote is always resolvable, because there is always a viewer. The "null for guests" branch never fires.

If the profile itself goes public later, this route should move with it โ€” it is one line in routes/api/v1/auth/auth.php. It should not go public on its own.


C. Your open question: rating_count is all time ๐Ÿ’ฌ

You offered three options and recommended option 1. We took it.

summary.rating_count and summary.rating_breakdown are both all-time and both computed the same way, so the caption and the bars cannot disagree. There is no rating_count_this_year.

Action for you: change the caption to N reviews. Dropping "this year" is the whole fix โ€” the mock's breakdown was already all-time, and year-scoping one number while the other stayed lifetime is what made them disagree in the first place.

And the second decision you asked us to pin: average_rating is 0, never null.

"summary": { "average_rating": 0, "rating_count": 0, "rating_breakdown": { "5": 0, "4": 0, "3": 0, "2": 0, "1": 0 } }

One value for "no rating yet" rather than two the client has to test for. It is stable โ€” a mentor with no reviews reads 0 today and will read 0 forever.

Note this is the opposite call from the one on the dashboard banner, where average_rating is null until somebody rates the mentor. Different question: the banner shows a single figure with nothing beside it, so 0.00 reads as "a mentor nobody liked", and null is your own signal to hide the indicator. The reviews tab shows the number next to a count of zero, which is unambiguous.


D. Deviations from your spec, in full โ—

Small, and none of them require a redesign.

1. Report returns 201, not 200.

"Success returns 200 with an empty data object or the created report id. Either is fine."

We took the id โ€” and 201, because every other report endpoint in the application returns 201 and there was no reason for this one to be the odd one out. Body:

{ "status": "success", "message": "Report submitted successfully.", "data": { "report_id": "9b20โ€ฆ" } }

Make sure the dialog's success branch is not pinned to === 200.

2. Review ids are UUIDs, not rev_01j9x8a2b3c4.

Your mock shows a prefixed id. Everything in this system is a plain UUID. They are opaque either way โ€” just do not parse them or assume a prefix.

3. per_page caps at 50.

You specified a default of 10; that is the default. The ceiling is new โ€” an unbounded per_page on a 1,500-row list is a denial-of-service button.

4. Two extra fields on each row.

updated_at alongside created_at, so an edited review can be labelled as edited if you ever want to. Ignore it otherwise.

4b. average_rating is a JSON number, and a whole one loses its decimal.

4.5 arrives as 4.5; 4.0 arrives as 4, and 0 as 0. Formatting to one decimal is the client's job โ€” toFixed(1), not a raw interpolation.

5. Booking cards are camelCase, review payloads are snake_case. โ—

You asked for snake_case and everything under /reviews and /user/{username}/reviews is snake_case. But SessionBookingResource โ€” which now also carries the review โ€” is camelCase throughout and predates this module:

{ "canBeReviewed": true, "review": { "rating": 5, "session_type": "โ€ฆ", "helpful_yes_count": 0 } }

An honest wart: camelCase wrapper, snake_case review object inside it. Renaming the whole booking resource is a breaking change for the sessions screen, so we did not do it as a side effect of a reviews build. Same offer as in analytics-api-response.md ยงD.1 โ€” say the word and it is one migration of the client's booking types.


E. Things you did not ask for, which you now have

1. The Review button no longer needs a probe call.

GET /mentee/bookings and GET /mentee/bookings/{id} now carry canBeReviewed and an embedded review on every card, eager-loaded. That covers BRD ยง15's "Completed: shows Review button (if no review submitted yet)" without a second round trip per card. GET /mentee/bookings/{id}/review exists for a dialog opened from a deep link, where no booking payload is in hand.

2. mentor_profiles.average_rating finally has something in it.

No application code ever wrote it. Seeded environments were worse than empty: DemoDataSeeder filled it with randomFloat(2, 4.0, 5.0) and QaTestAccountsSeeder with a flat 4.80, so staging mentors have been showing invented ratings this whole time โ€” and once rating_count landed beside the column, the pair started contradicting itself in public ("average_rating": "4.13" next to "rating_count": 0). Migration 2026_09_10_100003 reconciles it and both seeders have stopped fabricating it. Run migrations on staging.

Consequences, all now fixed:

Surface Was Now
Mentor search filter[rating] Silent no-op โ€” filtered on a constant Filters on a real average
Dashboard spotlight banner Hard-coded null Real average_rating + total_reviews
GET /mentor/analytics Hard-coded null Windowed average with a change percentage
Chat participant block Course reviews only Course and session reviews, one weighted average

The three nulls were deliberate โ€” the comments in those files say so โ€” because serving the column would have been inventing a rating. They are real numbers now.

3. session.review_received notifications.

The User Feedback switch on the notification settings screen has been shipped and inert since the settings module landed, because the only type it gated was course.reviewed. It now gates session reviews too. Mentor gets a database notification on a mentee's first review of a session; a revision is silent.

4. session.reviewed in the activity log, under the Sessions tab.


F. Your edge-case list, answered line by line

Your case Behaviour
No reviews at all average_rating: 0, rating_count: 0, breakdown all zeros, meta.total: 0. Tested.
rating=1 with zero rows Full-scope summary, empty list. The bars stay up. Tested.
Unknown username 404. Tested.
Deleted reviewer keeps the row โ— Not as specified โ€” see below.

The deleted-reviewer case is the one thing in your document we did not implement as written.

"Deleted reviewer: keep the review row with author name as plain text and null avatar, rather than dropping the row and shifting page contents."

SessionReviewResource handles a null author exactly that way. But session_reviews.user_id has cascadeOnDelete, so a hard-deleted account would take its reviews with it, and the mentor's summary would not be recomputed.

This is inert today. Nothing in this application hard-deletes a user and User has no SoftDeletes โ€” there is no account-deletion path at all, so the case cannot currently arise. Building a denormalised author-name column and a null-on-delete FK for a code path that does not exist would be speculative. When account deletion ships, it is roughly a day: nullOnDelete, a stored author_name, and a recalculate() call in the deletion path.

Flagging it rather than letting it look covered.


G. What happens next

Ours: nothing outstanding on your document.

Two things deliberately not built, both waiting on your call:

  1. No delete endpoint. A mentee can revise a review but not remove it. Course reviews have DELETE /courses/{course}/reviews/me; session reviews do not, because the review is evidence attached to a delivered, paid session. Say the word if it should match courses.
  2. Review reports are captured but not triaged. They land in feed_reports and are queryable, but GET /admin/user-reports groups by user target only, so they do not surface in the admin queue yet. That is admin-dashboard work.

Yours:

  1. Change the caption to N reviews. ยงC.
  2. Delete the mocks: RATING_BREAKDOWN, the hardcoded 4.9, the single ReviewCard, and the toast.success behind the report TODO.
  3. Wire the sort Select. Its four values are exactly the four the API takes, so it is a query param, not a mapping table.
  4. Point ReportDialog's onSubmit at POST /reviews/{id}/report and keep the 409 copy you already use for posts.
  5. Confirm useSubmitReview reads data.review. It has been posting into a 404, so whatever it does on success has never run against a real response.
  6. Drop any client-side "has this been reviewed" derivation. canBeReviewed is on the booking card.