# Model Reviews on Products and Channels ## Rating value Let's assume the rating interface is using a system with 5 stars. The `rating` field of a review is a number between -100 and 100. We first have to know how to encode the number of stars in the `rating` field. | Number of stars | Rating value | | --- | --- | | ★★★★★ | 5 | | ★★★★☆ | 4 | | ★★★☆☆ | 3 | | ★★☆☆☆ | 2 | | ★☆☆☆☆ | 1 | | ☆☆☆☆☆ | 0 | Here, we use the `rating` field to represent the number of stars. We could also use this field to store a percentage (ex: `95` would mean 95 %), or to store a like/dislike information (`1` would mean like, `-1` would mean dislike). ## Review approval process If you do not need any approval process, skip this part If we have an approval process for a review to be used for a product or a channel, we model the approval process with a state machine. First of all, we create the `approved` state: `POST //states` with: ```json { "key": "approved", "type": "ReviewState", "roles": ["ReviewIncludedInStatistics"] } ``` ```java final State approvedState = apiRoot .states() .post( StateDraft .builder() .key("approved") .type(StateTypeEnum.REVIEW_STATE) .roles(List.of(StateRoleEnum.REVIEW_INCLUDED_IN_STATISTICS)) .build() ) .executeBlocking() .getBody(); ``` ```ts const approvedStateDraft = apiRoot .states() .post({ body: { key: "approved", type: "ReviewState", roles: ["ReviewIncludedInStatistics"] } }) .execute(); ``` ```php states() ->post( StateDraftBuilder::of() ->withKey('approved') ->withType('ReviewState') ->withRoles(['ReviewIncludedInStatistics']) ->build(), ) ->execute(); ``` ```cs var approvedStateDraft = projectApiRoot .States() .Post(new StateDraft() { Key = "approved", Type = IStateTypeEnum.ReviewState, Roles = new List { IStateRoleEnum.ReviewIncludedInStatistics } }) .ExecuteAsync() .Result; ``` ```sh #!/bin/sh curl -sH "Authorization: Bearer ACCESS_TOKEN" https://api.{region}.commercetools.com/{projectKey}/states -d @- << EOF { "key": "approved", "type": "ReviewState", "roles": ["ReviewIncludedInStatistics"] } EOF ``` Then we create the initial `pending-approval` state, which has a possible transition to the `approved` state: `POST //states` with: ```json { "key": "pending-approval", "type": "ReviewState", "initial": true, "transitions": [ { "typeId": "state", "id": "" } ] } ``` ```java final State state = apiRoot .states() .post( StateDraft .builder() .key("pending-approval") .type(StateTypeEnum.REVIEW_STATE) .initial(true) .transitions( List.of( StateResourceIdentifier.builder().id("").build() ) ) .build() ) .executeBlocking() .getBody(); ``` ```ts const stateDraft = apiRoot .states() .post({ body: { key: 'pending-approval', type: 'ReviewState', initial: true, transitions: [ { typeId: 'state', id: '', }, ], }, }) .execute(); ``` ```php states() ->post( StateDraftBuilder::of() ->withKey('pending-approval') ->withType('ReviewState') ->withInitial(true) ->withTransitions( StateResourceIdentifierCollection::of()->add( StateResourceIdentifierBuilder::of() ->withId('') ->build(), ), ) ->build(), ) ->execute(); ``` ```cs var stateDraft = projectApiRoot .States() .Post(new StateDraft() { Key = "pending-approval", Type = IStateTypeEnum.ReviewState, Initial = true, Transitions = new List { new StateResourceIdentifier() { Id = "" } } }) .ExecuteAsync() .Result; ``` ```sh #!/bin/sh curl -sH "Authorization: Bearer ACCESS_TOKEN" https://api.{region}.commercetools.com/{projectKey}/states -d @- << EOF { "key": "pending-approval", "type": "ReviewState", "initial": true, "transitions": [ { "typeId": "state", "id": "" } ] } EOF ``` ## Create Reviews Now we can create a review in the initial state `pending-approval`: `POST //reviews` with: ```json { "key": "review-1", "target": { "typeId": "product", "id": "" }, "rating": 4, "state": { "key": "pending-approval" } } ``` ```java final Review review = apiRoot .reviews() .post( ReviewDraft .builder() .key("review-1") .rating(4) // state should be removed if you have not created a state machine .state(StateResourceIdentifier.builder().key("pending-approval").build()) .target(ProductResourceIdentifier.builder().id("").build()) .build() ) .executeBlocking() .getBody(); ``` ```ts const reviewDraft = apiRoot .reviews() .post({ body: { key: 'review-1', rating: 4, // state should be removed if you have not created a state machine state: { typeId: 'state', key: 'pending-approval' }, target: { typeId: 'product', id: '' }, }, }) .execute(); ``` ```php reviews() ->post( ReviewDraftBuilder::of() ->withKey('review-1') ->withRating(4) // withState should be removed if you have not created a state machine ->withState( StateResourceIdentifierBuilder::of() ->withKey('pending-approval') ->build(), ) ->withTarget( ProductResourceIdentifierBuilder::of() ->withId('') ->build(), ) ->build(), ) ->execute(); ``` ```cs var reviewDraft = projectApiRoot .Reviews() .Post(new ReviewDraft() { Key = "review-1", Rating = 4, // State should be removed if you have not created a state machine State = new StateResourceIdentifier() { Key = "pending-approval" }, Target = new ProductResourceIdentifier() { Id = "" } }) .ExecuteAsync() .Result; ``` ```sh #!/bin/sh curl -sH "Authorization: Bearer ACCESS_TOKEN" https://api.{region}.commercetools.com/{projectKey}/reviews -d @- << EOF { "key": "review-1", "target": { "typeId": "product", "id": "" }, "rating": 4, "state": { "key": "pending-approval" } } EOF ``` remove the field `state` if you have not created any state machine first ## Query which Reviews should be approved skip this part if you do not have any approval process We can query which reviews should be approved with a [where predicate](/api/predicates/query.md) `GET //reviews` with query parameters: - `where=state(id in (""))` ```java final ReviewPagedQueryResponse reviewPagedQueryResponse = apiRoot .reviews() .get() .withWhere("state(id in (\"\"))") .executeBlocking() .getBody(); ``` ```ts const reviewsToApproveQuery = apiRoot .reviews() .get({ queryArgs: { where: 'state(id in (""))' }, }) .execute(); ``` ```php reviews() ->get() ->withWhere("state(id in (\"\"))") ->execute() ->getResults(); ``` ```cs var reviewsToApproveQuery = projectApiRoot .Reviews() .Get() .WithWhere("state(id in (\"\"))") .ExecuteAsync() .Result; ``` ```sh curl -sH "Authorization: Bearer ACCESS_TOKEN" https://api.{region}.commercetools.com/{projectKey}/reviews?where=state%28id+in+%28%22%22%29%29 ``` ## Approve a Review skip this part if you do not have any approval process We can now approve the review `review-1`: `POST //reviews/key=review-1` with: ```json { "version": 1, "actions": [ { "action": "transitionState", "state": { "key": "approved" } } ] } ``` ```java final Review reviewUpdated = apiRoot .reviews() .withKey("review-1") .post( ReviewUpdate .builder() .version(1L) .plusActions(actionbuilder -> actionbuilder .transitionStateBuilder() .state(StateResourceIdentifier.builder().key("approved").build()) ) .build() ) .executeBlocking() .getBody(); ``` ```ts const reviewUpdated = apiRoot .reviews() .withKey({ key: "review-1" }) .post({ body: { version: 1, actions: [{ action: "transitionState", state: { typeId: "state", key: "approved" } }] } }) .execute(); ``` ```php reviews() ->withKey('review-1') ->post( ReviewUpdateBuilder::of() ->withVersion(1) ->withActions( ReviewUpdateActionCollection::of()->add( ReviewTransitionStateActionBuilder::of() ->withState( StateResourceIdentifierBuilder::of() ->withKey('approved') ->build(), ) ->build(), ), ) ->build(), ) ->execute(); ``` ```cs var reviewUpdateCommand = projectApiRoot .Reviews() .WithKey("review-1") .Post(new ReviewUpdate() { Version = 1, Actions = new List { new ReviewTransitionStateAction() { State = new StateResourceIdentifier() { Key="approved" } } } }) .ExecuteAsync() .Result; ``` ```sh #!/bin/sh curl -sH "Authorization: Bearer ACCESS_TOKEN" https://api.{region}.commercetools.com/{projectKey}/reviews -d @- << EOF { "version": 1, "actions": [ { "action": "transitionState", "state": { "key": "approved" } } ] } EOF ``` ## Display Products We can display all products: - that have at least 3 stars (average rating superior to 3) - with facets about the number of products rated with an average in the different ranges 0 to 1 star, 1 to 2 stars, 2 to 3 stars, 3 to 4 stars and 4 to 5 stars. - sorted by average ratings For that, we use the [Product Search API](/api/projects/product-search.md) with a query, a facets, and a sort expression: ```java import com.commercetools.api.client.ProjectApiRoot; import com.commercetools.api.models.product_search.ProductPagedSearchResponse; import com.commercetools.api.models.product_search.ProductSearchFacetRangesExpression; import com.commercetools.api.models.product_search.ProductSearchRequest; import com.commercetools.api.models.search.SearchFilterExpression; import com.commercetools.api.models.search.SearchNumberRangeExpression; import com.commercetools.api.models.search.SearchSortOrder; import io.vrap.rmf.base.client.ApiHttpResponse; import java.util.concurrent.CompletionStage; public class ProductSearchByRatingExample { public CompletionStage> search(ProjectApiRoot apiRoot) { ProductSearchRequest request = ProductSearchRequest.builder() // query.filter: reviewRatingStatistics.averageRating >= 3 .query(SearchFilterExpression.builder() .plusFilter(SearchNumberRangeExpression.builder() .range(rangeBuilder -> rangeBuilder .field("reviewRatingStatistics.averageRating") .gte(3.0)) .build()) .build()) // facets: ranges facet bucketing averageRating in 1-point bands .plusFacets(ProductSearchFacetRangesExpression.builder() .ranges(rangesBuilder -> rangesBuilder .name("averageRating") .field("reviewRatingStatistics.averageRating") .plusRanges(r -> r.from(0).to(1)) .plusRanges(r -> r.from(1).to(2)) .plusRanges(r -> r.from(2).to(3)) .plusRanges(r -> r.from(3).to(4)) .plusRanges(r -> r.from(4).to(5))) .build()) // sort: descending by averageRating .plusSort(sortBuilder -> sortBuilder .field("reviewRatingStatistics.averageRating") .order(SearchSortOrder.DESC)) .build(); return apiRoot.products() .search() .post(request) .execute(); // or .executeBlocking() for a synchronous call } } ``` ```ts import { createApiBuilderFromCtpClient } from '@commercetools/platform-sdk'; import type { ProductPagedSearchResponse, ProductSearchRequest, } from '@commercetools/platform-sdk'; import { ctpClient } from './build-client'; // your ClientBuilder-based http client const apiRoot = createApiBuilderFromCtpClient(ctpClient); async function searchByAverageRating( projectKey: string ): Promise { const request: ProductSearchRequest = { query: { filter: [ { range: { field: 'reviewRatingStatistics.averageRating', gte: 3, }, }, ], }, facets: [ { ranges: { name: 'averageRating', field: 'reviewRatingStatistics.averageRating', ranges: [ { from: 0, to: 1 }, { from: 1, to: 2 }, { from: 2, to: 3 }, { from: 3, to: 4 }, { from: 4, to: 5 }, ], }, }, ], sort: [ { field: 'reviewRatingStatistics.averageRating', order: 'desc', }, ], }; const response = await apiRoot .withProjectKey({ projectKey }) .products() .search() .post({ body: request }) .execute(); return response.body; } ``` ```php withProjectKey($projectKey) */ $productSearchRequest = ProductSearchRequestBuilder::of() // query.filter: reviewRatingStatistics.averageRating >= 3 ->withQuery( SearchFilterExpressionBuilder::of() ->withFilter( (new SearchQueryExpressionCollection())->add( SearchNumberRangeExpressionBuilder::of() ->withRange( SearchNumberRangeValueBuilder::of() ->withField('reviewRatingStatistics.averageRating') ->withGte(3) ->build(), ) ->build(), ), ) ->build(), ) // facets: ranges facet bucketing averageRating in 1-point bands ->withFacets( (new ProductSearchFacetExpressionCollection())->add( ProductSearchFacetRangesExpressionBuilder::of() ->withRanges( ProductSearchFacetRangesValueBuilder::of() ->withName('averageRating') ->withField('reviewRatingStatistics.averageRating') ->withRanges( (new ProductSearchFacetRangesFacetRangeCollection()) ->add( ProductSearchFacetRangesFacetRangeBuilder::of() ->withFrom(0) ->withTo(1) ->build(), ) ->add( ProductSearchFacetRangesFacetRangeBuilder::of() ->withFrom(1) ->withTo(2) ->build(), ) ->add( ProductSearchFacetRangesFacetRangeBuilder::of() ->withFrom(2) ->withTo(3) ->build(), ) ->add( ProductSearchFacetRangesFacetRangeBuilder::of() ->withFrom(3) ->withTo(4) ->build(), ) ->add( ProductSearchFacetRangesFacetRangeBuilder::of() ->withFrom(4) ->withTo(5) ->build(), ), ) ->build(), ) ->build(), ), ) // sort: descending by averageRating ->withSort( (new SearchSortingCollection())->add( SearchSortingBuilder::of() ->withField('reviewRatingStatistics.averageRating') ->withOrder('desc') ->build(), ), ) ->build(); $response = $apiRoot ->products() ->search() ->post($productSearchRequest) ->execute(); ``` ```cs using System.Collections.Generic; using System.Threading.Tasks; using commercetools.Sdk.Api.Client; using commercetools.Sdk.Api.Models.ProductSearches; using commercetools.Sdk.Api.Models.Searches; public class ProductSearchByRatingExample { public async Task SearchAsync(ProjectApiRoot projectApiRoot) { var request = new ProductSearchRequest { // query.filter: reviewRatingStatistics.averageRating >= 3 Query = new SearchFilterExpression { Filter = new List { new SearchNumberRangeExpression { Range = new SearchNumberRangeValue { Field = "reviewRatingStatistics.averageRating", Gte = 3 } } } }, // facets: ranges facet bucketing averageRating in 1-point bands Facets = new List { new ProductSearchFacetRangesExpression { Ranges = new ProductSearchFacetRangesValue { Name = "averageRating", Field = "reviewRatingStatistics.averageRating", Ranges = new List { new ProductSearchFacetRangesFacetRange { From = 0, To = 1 }, new ProductSearchFacetRangesFacetRange { From = 1, To = 2 }, new ProductSearchFacetRangesFacetRange { From = 2, To = 3 }, new ProductSearchFacetRangesFacetRange { From = 3, To = 4 }, new ProductSearchFacetRangesFacetRange { From = 4, To = 5 } } } } }, // sort: descending by averageRating Sort = new List { new SearchSorting { Field = "reviewRatingStatistics.averageRating", Order = ISearchSortOrder.Desc } } }; return await projectApiRoot .Products() .Search() .Post(request) .ExecuteAsync(); } } ``` ```json { "query": { "filter": [ { "range": { "field": "reviewRatingStatistics.averageRating", "gte": 3 } } ] }, "facets": [ { "ranges": { "name": "averageRating", "field": "reviewRatingStatistics.averageRating", "ranges": [ {"from": 0, "to": 1}, {"from": 1, "to": 2}, {"from": 2, "to": 3}, {"from": 3, "to": 4}, {"from": 4, "to": 5} ] } } ], "sort": [ { "field": "reviewRatingStatistics.averageRating", "order": "desc" } ] } ``` The response gives us the following information: ```json { "offset": 0, "count": , "total": , "results": [ { "id": "", [...] "reviewRatingStatistics": { "averageRating": 4.07037, "highestRating": 5, "lowestRating": 3, "count": 1009, "ratingsDistribution": { "5": 254, "4": 572, "3": 183 } } }, { "id": "", [...] "reviewRatingStatistics": { "averageRating": 2.97677, "highestRating": 1, "lowestRating": 0.2, "count": 3875, "ratingsDistribution": { "4": 145, "3": 3495, "2": 235 } } }, [...] ], "facets": { "reviewRatingStatistics.averageRating": { "type": "range", "dataType": "number", "ranges": [ { "from": 0.0, "to": 1.0, "count": 0 }, { "from": 1.0, "to": 2.0, "count": 15 }, { "from": 2.0, "to": 3.0, "count": 78 }, { "from": 3.0, "to": 4.0, "count": 242 }, { "from": 4.0, "to": 5.0, "count": 145 } ] } } } ``` ## Display one Product The following information can be found in the JSON data of one product: - average rating: `reviewRatingStatistics.averageRating`. The value is already rounded to 5 decimals. Depending on your need, you may have to round it more. - number of reviews: `reviewRatingStatistics.count`. - distribution of ratings: `reviewRatingStatistics.ratingsDistribution`: ★★★★★: `reviewRatingStatistics.ratingsDistribution.5` ★★★★☆: `reviewRatingStatistics.ratingsDistribution.4` ★★★☆☆: `reviewRatingStatistics.ratingsDistribution.3` ★★☆☆☆: `reviewRatingStatistics.ratingsDistribution.2` ★☆☆☆☆: `reviewRatingStatistics.ratingsDistribution.1` ☆☆☆☆☆: `reviewRatingStatistics.ratingsDistribution.0` If one field for one rating does not exist, it means that there are no reviews with that rating value. #### Display all Reviews of one Product To retrieve all reviews for one product, sorted by rating: `GET //reviews` with query parameter: - `where = target(typeId = "product" and id = "")` - `sort = rating desc` ```java final ReviewPagedQueryResponse review = apiRoot .reviews() .get() .withWhere("target(typeId = \"product\" and id = \"\")") .withSort("rating desc") .executeBlocking() .getBody(); ``` ```ts const reviewQuery = apiRoot .reviews() .get({ queryArgs: { where: "target(typeId = \"product\" and id = \"\")", sort: "rating desc" } }) .execute(); ``` ```php reviews() ->get() ->withWhere("target(typeId = \"product\" and id = \"\")") ->withSort('rating desc') ->execute() ->getResults(); ``` ```cs var reviewQuery = projectApiRoot .Reviews() .Get() .WithWhere("target(typeId = \"product\" and id = \"\")") .WithSort("rating desc") .ExecuteAsync() .Result; ``` ```sh curl -sH "Authorization: Bearer ACCESS_TOKEN" https://api.{region}.commercetools.com/{projectKey}/reviews?where=target%28typeId+%3D+%22product%22+and+id+%3D+%22%22%29&sort=rating+desc ``` To query only the reviews used for the rating statistics of the product: `GET /reviews` with query parameters: - `where = target(typeId = "product" and id = "") and includedInStatistics = true` - `sort = rating desc` ```java final ReviewPagedQueryResponse reviewQuery = apiRoot .reviews() .get() .withWhere( "target(typeId = \"product\" and id = \"\") and includedInStatistics = true" ) .withSort("rating desc") .executeBlocking() .getBody(); ``` ```ts const reviewQuery = apiRoot .reviews() .get({ queryArgs: { where: "target(typeId = \"product\" and id = \"\") and includedInStatistics = true", sort: "rating desc" } }) .execute(); ``` ```php reviews() ->get() ->withWhere( "target(typeId = \"product\" and id = \"\") and includedInStatistics = true", ) ->withSort('rating desc') ->execute() ->getResults(); ``` ```cs var reviewQuery = projectApiRoot .Reviews() .Get() .WithWhere("target(typeId = \"product\" and id = \"\") and includedInStatistics = true") .WithSort("rating desc") .ExecuteAsync() .Result; ``` ```sh curl -sH "Authorization: Bearer ACCESS_TOKEN" https://api.{region}.commercetools.com/{projectKey}/reviews?where=target%28typeId+%3D+%22product%22+and+id+%3D+%22%22%29+and+includedInStatistics=true&sort=rating+desc ``` ## Related pages - [Area overview page with navigation](/tutorials.md) - [Previous page: Model static product bundles](/tutorials/product-bundles.md) - [Next page: Model assortments with Product Selections](/tutorials/product-selections.md) - [Search documentation and API specs](/search.md)