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, viauseSubmitReview). 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_ratingisnulluntil somebody rates the mentor. Different question: the banner shows a single figure with nothing beside it, so0.00reads as "a mentor nobody liked", andnullis 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
200with 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:
- 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. - Review reports are captured but not triaged. They land in
feed_reportsand are queryable, butGET /admin/user-reportsgroups by user target only, so they do not surface in the admin queue yet. That is admin-dashboard work.
Yours:
- Change the caption to
N reviews. ยงC. - Delete the mocks:
RATING_BREAKDOWN, the hardcoded4.9, the singleReviewCard, and thetoast.successbehind the report TODO. - Wire the sort
Select. Its four values are exactly the four the API takes, so it is a query param, not a mapping table. - Point
ReportDialog'sonSubmitatPOST /reviews/{id}/reportand keep the409copy you already use for posts. - Confirm
useSubmitReviewreadsdata.review. It has been posting into a404, so whatever it does on success has never run against a real response. - Drop any client-side "has this been reviewed" derivation.
canBeReviewedis on the booking card.