---
feature: Rating management
slug: ratings
surface: admin
consumer: react
version: 1.1.0
status: ready
brd_version: "1.3"
brd_ids: [ADMIN-019]
business_rules: [BR-28, BR-29]
decisions: [TD-27, TD-160]
schema_tables: [reviews, review_tags, review_tag_links, providers, customers, drivers]
related_docs: [orders.md, providers.md, customers.md, drivers.md]
last_updated: 2026-11-10
---

# Rating management — React Dashboard Integration Guide

> **Base URL:** `{HOST}/api/v1/admin` · Shared rules (headers, envelope, errors,
> pagination, axios setup, TS types): [API_CONVENTIONS.md](../API_CONVENTIONS.md)

## Version history

Newest first. Never edit an old row.

| Version | Date | Type | Summary | Dashboard action required |
|---------|------|------|---------|---------------------------|
| 1.1.0 | 2026-11-10 | MINOR | Customer references in admin review rows now also carry `customer.user_id`, the **user** uuid that opens the customer at `GET /admin/users/{id}`; `customer.id` is unchanged (the customer-profile uuid, still what `filters[customer_id]` takes). Fixes the order → customer link that returned 404 (QA-03). | Link to `/users/{customer.user_id}` instead of `/users/{customer.id}`; ignore the field if absent. |
| 1.0.1 | 2026-11-08 | PATCH | Rating submission (CUST-021, PROV-024) is built; the list fills from real submissions. | None. |
| 1.0.0 | 2026-11-02 | Initial | Ratings list and details for every direction, publish / hide with an audited note, averages that leave hidden ratings out. | Build the Ratings page. |

## Changelog

### 1.1.0 — 2026-11-10

- **Added:** Customer references in admin review rows now also carry `customer.user_id`, the **user** uuid that opens the customer at `GET /admin/users/{id}`; `customer.id` is unchanged (the customer-profile uuid, still what `filters[customer_id]` takes). Fixes the order → customer link that returned 404 (QA-03).
- **Note:** the JSON examples in this guide were captured before `customer.user_id` existed and do not show it; the current captured responses are in the Postman collection.
- **Breaking:** No. **App action required:** Link to `/users/{customer.user_id}` instead of `/users/{customer.id}`; ignore the field if absent.

### 1.0.1 — 2026-11-08

- **Changed:** Rating submission (CUST-021, PROV-024) is built; the list fills from real submissions.
- **Breaking:** No. **App action required:** None.

### 1.0.0 — 2026-11-02

- **Added:** `GET /reviews`, `GET /reviews/{review}`, `POST /reviews/{review}/publish`, `POST /reviews/{review}/hide`.
- **Breaking:** No (new). **BRD / decisions:** ADMIN-019, BR-28, TD-160.

## 1. Business summary

Ratings belong to completed orders (BR-28): a customer rates the provider, a provider rates the customer, and a provider may rate the driver of a trip. The admin sees every rating, opens its details, and **publishes** it (keeps it visible and marks it reviewed) or **hides** it. A hidden rating disappears from the apps and its stars leave the rated party's saved average (`rating_avg` / `rating_count` on the provider, customer or driver). Publishing again restores them. Ratings are never deleted from the dashboard (owner decision, TD-160), so the record stays.

"Review before publication" is the `filters[moderated]=0` queue: ratings no admin has published or hidden yet. Ratings are submitted through the customer and provider rating features ([client/ratings](../client/ratings.md) CUST-021 and [provider/ratings](../provider/ratings.md) PROV-024, both built 2026-11-02, 1.0.0), so this list fills from real submissions. A provider → driver rating is still not built.

## 2. Pages and permissions

| Page / component | Purpose | Endpoint(s) | RBAC permission (route name) | Section · action |
|------------------|---------|-------------|------------------------------|------------------|
| RatingsPage | All ratings, filters, unreviewed queue | `GET /reviews` | `admin.reviews.index` | ratings · view |
| RatingDrawer | One rating | `GET /reviews/{review}` | `admin.reviews.show` | ratings · view |
| Publish button | Visible + reviewed | `POST /reviews/{review}/publish` | `admin.reviews.publish` | ratings · update |
| Hide button | Hidden + reviewed | `POST /reviews/{review}/hide` | `admin.reviews.hide` | ratings · update |

## 3. List / table

`GET /reviews` — `page`, `per_page`, `search` (order code), `sort[created_at]`, `sort[stars]` (default newest first). Filters: `filters[direction]` (`customer_to_provider`, `provider_to_customer`, `provider_to_driver`), `filters[is_visible]`, `filters[moderated]` (`0` = unreviewed queue), `filters[stars]` (1–5), `filters[order_kind]` (`service`, `package_session`), `filters[provider_id]`, `filters[customer_id]` (profile uuids), `filters[created_from]`, `filters[created_to]` (`YYYY-MM-DD`). An unknown value is a 422 (Postman: "422 Reviews Unknown Direction"). Rows are under `data.reviews`. Postman: `admin.reviews.index` › "200 Reviews List" (one row shown):

```json
{
    "status": "success",
    "message": "Data retrieved successfully",
    "data": {
        "reviews": [
            {
                "id": "01a19be7-1807-72ed-9ec5-8b41060d3a66",
                "direction": {
                    "value": "customer_to_provider",
                    "label": "Customer rated provider"
                },
                "order": {
                    "id": "01a19be7-1802-7025-94e6-ff085db62edd",
                    "code": "JSH-albq57463947",
                    "kind": {
                        "value": "service",
                        "label": "Service"
                    }
                },
                "customer": {
                    "id": "01a19be7-17fb-727a-8045-2b1893196e59",
                    "full_name": "Daphne Farrell"
                },
                "provider": {
                    "id": "01a19be7-17ff-7396-80ec-97d6e015fd95",
                    "display_name": "Emely Nicolas"
                },
                "driver": null,
                "stars": 5,
                "comment": null,
                "is_visible": true,
                "is_moderated": true,
                "created_at": "2026-11-02T10:56:15+00:00"
            }
        ],
        "links": {
            "first": "http://localhost/api/v1/admin/reviews?page=1",
            "last": "http://localhost/api/v1/admin/reviews?page=1",
            "prev": null,
            "next": null
        },
        "meta": {
            "current_page": 1,
            "from": 1,
            "last_page": 1,
            "links": [
                {
                    "url": null,
                    "label": "&laquo; Previous",
                    "page": null,
                    "active": false
                },
                {
                    "url": "http://localhost/api/v1/admin/reviews?page=1",
                    "label": "1",
                    "page": 1,
                    "active": true
                },
                {
                    "url": null,
                    "label": "Next &raquo;",
                    "page": null,
                    "active": false
                }
            ],
            "path": "http://localhost/api/v1/admin/reviews",
            "per_page": 15,
            "to": 3,
            "total": 3
        }
    }
}
```

`driver` is set only for a provider→driver rating.

## 4. Details view

`GET /reviews/{review}` adds the order status and completion time, `trip`, `reviewer` and `reviewee` (`{role, id, name}` — profile uuids), `private_note` (team-only, never shown to apps), `tags`, `moderated_by`, `moderated_at`. Unknown or integer id: 404 (Postman: "404 Review Not Found"). Postman: `admin.reviews.show` › "200 Review Details":

```json
{
    "status": "success",
    "message": "Data retrieved successfully",
    "data": {
        "id": "01a19be7-19f8-738a-92c1-e5bb8fb2a00f",
        "direction": {
            "value": "customer_to_provider",
            "label": "Customer rated provider"
        },
        "order": {
            "id": "01a19be7-19f1-720a-ac27-e0107ed920a8",
            "code": "JSH-wkgw51565611",
            "kind": {
                "value": "service",
                "label": "Service"
            },
            "status": {
                "value": "completed",
                "label": "Completed"
            },
            "completed_at": "2026-11-02T10:56:16+00:00"
        },
        "customer": {
            "id": "01a19be7-19eb-716e-90d0-dad2ccd2b1ff",
            "full_name": "Mekhi Davis MD"
        },
        "provider": {
            "id": "01a19be7-19ee-73d7-929c-fbfedbc914a5",
            "display_name": "Nikita Emmerich"
        },
        "driver": null,
        "stars": 5,
        "comment": "Great",
        "is_visible": true,
        "is_moderated": false,
        "created_at": "2026-11-02T10:56:16+00:00",
        "trip": null,
        "reviewer": {
            "role": {
                "value": "customer",
                "label": "Customer"
            },
            "id": "01a19be7-19eb-716e-90d0-dad2ccd2b1ff",
            "name": "Mekhi Davis MD"
        },
        "reviewee": {
            "role": {
                "value": "provider",
                "label": "Provider"
            },
            "id": "01a19be7-19ee-73d7-929c-fbfedbc914a5",
            "name": "Nikita Emmerich"
        },
        "private_note": "Customer called support",
        "tags": [
            {
                "id": "01a19be7-19fe-737a-889f-4473b517ea11",
                "label": "On time"
            }
        ],
        "moderated_by": null,
        "moderated_at": null
    }
}
```

## 5. Forms (create / edit)

N/A — ratings are written by the rating features; the dashboard only moderates. There is no delete (`DELETE` answers 405).

## 6. Actions (non-CRUD)

### 6.1 Publish — `POST /reviews/{review}/publish` · 6.2 Hide — `POST /reviews/{review}/hide`

Body: optional `note` (string ≤ 500) — the reason, kept in the audit log only. Both return the details. Each stamps `moderated_by` / `moderated_at`, writes an audit row (`reviews.published` / `reviews.hidden`), and — when visibility actually changes — recomputes the rated party's average from visible ratings. Repeating the current visibility only re-stamps the moderation.

Postman: `admin.reviews.hide` › "200 Review Hidden", "422 Review Note Too Long"; `admin.reviews.publish` › "200 Review Published", "422 Review Publish Note Too Long", "404 Review Publish Unknown".

```json
{
    "status": "success",
    "message": "Rating hidden.",
    "data": {
        "id": "01a19be7-1be0-7099-98e9-32b402f2ba6b",
        "direction": {
            "value": "customer_to_provider",
            "label": "Customer rated provider"
        },
        "order": {
            "id": "01a19be7-1bdb-738b-918b-924d7edce987",
            "code": "JSH-rhbi31713938",
            "kind": {
                "value": "service",
                "label": "Service"
            },
            "status": {
                "value": "completed",
                "label": "Completed"
            },
            "completed_at": "2026-11-02T10:56:16+00:00"
        },
        "customer": {
            "id": "01a19be7-1bd8-72c9-bffa-78fcd5615606",
            "full_name": "Dale Wiegand"
        },
        "provider": {
            "id": "01a19be7-1bc9-7026-8f9b-682eebac6d82",
            "display_name": "Mazie Bogan"
        },
        "driver": null,
        "stars": 1,
        "comment": null,
        "is_visible": false,
        "is_moderated": true,
        "created_at": "2026-11-02T10:56:16+00:00",
        "trip": null,
        "reviewer": {
            "role": {
                "value": "customer",
                "label": "Customer"
            },
            "id": "01a19be7-1bd8-72c9-bffa-78fcd5615606",
            "name": "Dale Wiegand"
        },
        "reviewee": {
            "role": {
                "value": "provider",
                "label": "Provider"
            },
            "id": "01a19be7-1bc9-7026-8f9b-682eebac6d82",
            "name": "Mazie Bogan"
        },
        "private_note": null,
        "tags": [],
        "moderated_by": {
            "id": "01a19be7-1bc2-72a2-8d42-06ad5d76129e",
            "full_name": "Devante Upton"
        },
        "moderated_at": "2026-11-02T10:56:16+00:00"
    }
}
```

## 7. Statuses and enums

| Field | Values |
|-------|--------|
| `direction` | `customer_to_provider`, `provider_to_customer`, `provider_to_driver` |
| `order.kind` | `service`, `package_session` |
| `reviewer.role` / `reviewee.role` | `customer`, `provider`, `driver` |
| `is_visible` / `is_moderated` | booleans; unreviewed = `is_moderated: false` |

## Data model

Reads `reviews` (+ `review_tag_links`, `review_tags`); writes `reviews.is_visible`, `moderated_by`, `moderated_at`, and `rating_avg` / `rating_count` on `providers` (customer→provider), `customers` (provider→customer) or `drivers` (provider→driver). `deleted_at` is never set from the dashboard.

## 8. TypeScript types

```ts
type EnumObj<T extends string> = { value: T; label: string };
type Direction = 'customer_to_provider' | 'provider_to_customer' | 'provider_to_driver';
interface ReviewRow {
  id: string;
  direction: EnumObj<Direction>;
  order: { id: string; code: string; kind: EnumObj<'service' | 'package_session'> };
  customer: { id: string | null; user_id: string | null; full_name: string | null };
  provider: { id: string | null; display_name: string | null };
  driver: { id: string; full_name: string | null } | null;
  stars: 1 | 2 | 3 | 4 | 5;
  comment: string | null;
  is_visible: boolean;
  is_moderated: boolean;
  created_at: string;
}
interface Party { role: EnumObj<'customer' | 'provider' | 'driver'>; id: string | null; name: string | null }
interface ReviewDetails extends Omit<ReviewRow, 'order'> {
  order: ReviewRow['order'] & { status: EnumObj<string>; completed_at: string | null };
  trip: { id: string; code: string } | null;
  reviewer: Party; reviewee: Party;
  private_note: string | null;
  tags: { id: string | null; label: string | null }[];
  moderated_by: { id: string; full_name: string } | null;
  moderated_at: string | null;
}
```

## 9. Business rules enforced by the backend

- BR-28: ratings exist only for completed orders, one per order per direction (schema unique key; enforced at submission).
- TD-160: hidden ratings are excluded from averages; no delete from the dashboard; every moderation is audited.

## 10. Notifications triggered

None.

## 11. Integration checklist

- [ ] Default view: `filters[moderated]=0` as the "to review" tab.
- [ ] Show `private_note` only in the drawer, labelled team-only.
- [ ] Offer Publish when `is_visible` is false or `is_moderated` is false; offer Hide when `is_visible` is true.
- [ ] No delete button.

## 12. Acceptance scenarios

AC-ADMIN-019: `ReviewModerationTest` — both directions listed with type, parties, order type, stars, comment, date and visibility; unreviewed queue; details with both parties, note and tags; hiding removes the stars from the provider average and publishing restores them; a provider→driver rating updates the driver average; permissions and no delete route.

## 13. Open decisions and known limitations

| ID | Impact | Current behavior |
|----|--------|------------------|
| TD-160 | BRD "delete ratings when necessary" | Not offered (owner decision): hide instead; the record stays |
| CUST-021 / PROV-024 | Rating submission | Built (2026-11-02): customers rate the provider and providers rate the customer; the list fills from those submissions. Provider → driver rating is not built |
| Pre-moderation setting | "Review before publication" | New ratings are visible by default; the unreviewed queue is the review step. A "hide until approved" setting is not built |
