Skip to content

Product Review Moderation

Flow ID: AD-52 | Module(s): eshop | Complexity: Medium Last Updated: 2026-07-21 — 4.119.0 release resync: corrected REST review RBAC/auth and the customer store() pending-by-default + dedup behaviour

Business Context

Product review moderation provides administrators with tools to approve, reject, and manage customer-submitted product reviews. Customer reviews are hard-coded to enter the system as pending (active=0) and must be approved by an administrator before they appear on the storefront. The legacy admin interface at /product_reviews_admin.htm supports both individual and bulk status changes, with automatic email notifications to customers when their review status changes.

When a review is approved and the loyalty point system is enabled, the reviewer can automatically receive reward points as an incentive for leaving reviews. Both the single-review (setStatus()) and bulk (bulkSetStatus()) approval paths now award points identically (fixed in #16) — bulk resolves one customer id per review via getCustomerIds() and awards each approved review separately. The award is still applied as a raw total_points + N update, so it remains non-idempotent: repeated approve/pending/approve cycles double-award (see Loyalty Integration Gap).

A parallel write-capable path exists via the modern domain layer (Advisable\Domains\Product\Review with WriteService, WriteData, Validator, WriteRepository) and REST API (/rest/product/review, with HandlesWriteActions in src/Rest/Product/Controllers/Review.php:36). The REST writer fires RatingAggregator to keep shop_product.average_rating and review_count current (#174). Customer storefront submission via store() applies pending-by-default (active=0), server-forced identity/date fields, a 1-5 star check and one-review-per-product dedup (velora#42), but the REST path still does not apply the legacy admin's remaining moderation side-effects: no approval/rejection notification emails and no loyalty-point award. Those two side-effects still live exclusively in the legacy admin controller.


API Reference

REST Endpoints

The modern REST API exposes product reviews for read and write, but does not replicate the legacy moderation side-effects.

MethodPathActionAuthRoles
GET/rest/product/reviewindexguest
GET/rest/product/review/{id}showguest
GET/rest/product/review/itemitemguest
POST/rest/product/reviewstorecustomer— (customer-scoped storefront submission)
POST/rest/product/review/{id}updatebackendAUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING
DELETE/rest/product/review/{id}destroybackendAUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING

Read routes: application/config/rest_routes.php:563-568. Write routes: application/config/rest_routes.php:1603-1608. Policy: application/config/rest_policies.php:335-346 (defaults at :336).

RBAC is aligned with the legacy admin (comment at application/config/rest_policies.php:330-334): the Review::class policy defaults are [AUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING] — deliberately matching the legacy admin controller, so a MARKETING-only user has the same moderation surface on both layers. store is overridden to 'auth' => 'customer' (:344) for storefront submission (velora#42); only update/destroy inherit the backend + [ADMIN, MARKETING] defaults. AUTH_ROLE_PRODUCTS is not used by this policy.

Supported REST filters: id, productId, customerId, starPoints, active, lang, nickname (partial), content (partial). Supported sorts: id, productId, customerId, starPoints, reviewDate, active. The OpenAPI annotation at src/Rest/Product/Controllers/Review.php:57 documents the tri-state semantics: 0=pending, 1=approved, 2=rejected.

Legacy Admin Routes

RouteControllerMethodHTTPDescription
eshop/product_reviews_adminAdv_product_reviews_adminindex()GETList reviews (default filter: pending)
eshop/product_reviews_admin/{offset}Adv_product_reviews_adminindex($offset)GETPaginated review list
eshop/product_reviews_admin/resetIndexAdv_product_reviews_adminresetIndex()GETClear the prdSearch session key
eshop/product_reviews_admin/bulkSetStatusAdv_product_reviews_adminbulkSetStatus()POSTBatch approve/reject multiple reviews
eshop/product_reviews_admin/setStatus/{id}/{status}Adv_product_reviews_adminsetStatus($id, $status)GETApprove, reject or reset a single review

Routes declared at application/config/routes.php:414-419. Admin menu entry at application/config/admin_menu.php:586-592 (PRODUCTS group, customer_interaction.products).

Customer-facing Submission

RouteControllerMethodHTTPDescription
webrun/submit_product_reviewWebrunsubmit_product_review()POST (AJAX)Insert a new review in pending (active=0) state

Entry point: application/controllers/Webrun.php:32-73.


Data Model

shop_product_reviews

Defined in database/initial/initial.sql:1801-1822. This is the only table that stores actual product reviews. There is no Phinx migration for it — it exists purely as initial schema. There is no _mui companion table: the per-row lang VARCHAR(2) column is the sole multi-language discriminator.

ColumnTypeNotes
idINTPrimary key
product_idINT NOT NULLFK to shop_product
customer_idINT NOT NULLFK to shop_customer
is_email_sentTINYINT(1) DEFAULT 01 once the status-change email has been dispatched
star_pointsTINYINT(1) NOT NULL1-5
nicknameVARCHAR(150) NULLDisplay name (falls back to customer record)
review_dateDATETIME NOT NULLSubmission timestamp
activeTINYINT(1) DEFAULT 0Tri-state: 0=pending, 1=approved, 2=rejected (confirmed by inline DDL comment)
contentMEDIUMTEXT NULLReview body
langVARCHAR(2) NOT NULLLanguage code the review was written in

Indexes: PRIMARY (id), customer_id, product_id, product_id_lang, product_id_lang_date, active, active_lang, product_id_active_lang, product_id_active_lang_date, customer_product_id.

The active TINYINT(1) column is used as an enum rather than a boolean; it is not -1 for rejected. Any docs or client code assuming boolean semantics will silently mishandle rejected reviews.

shop_customer_reviews — NOT product reviews

Defined in database/initial/initial.sql:1209-1217. Despite the similar name, this table has nothing to do with product reviews. It is the dedup log for post-purchase "please review us" prompt emails sent to customers via the Facebook / Google / Skroutz review prompt job.

ColumnTypeNotes
idPrimary key
action_idINTCampaign id (1=Facebook, 2=Skroutz, 3=Google)
mailVARCHAR(250)Recipient email
entry_dateDATETIMEWhen the prompt was sent

Indexes: PRIMARY (id), mail, action_id_mail.

Campaign keys are declared at application/config/app.php:104-118:

php
$config['reviews'] = [
    'reviewForFacebook' => ['id' => 1, 'subject' => 'site.emails.prompt_review.facebook'],
    'reviewForSkroutz'  => ['id' => 2, 'subject' => 'site.emails.prompt_review.skroutz'],
    'reviewForGoogle'   => ['id' => 3, 'subject' => 'site.emails.prompt_review.google'],
];

Writer: Adv_order_model::sendMailForReviewToCustomers() inserts a row after dispatch (ecommercen/eshop/models/Adv_order_model.php:1916-1952). Reader: allowSendEmailForReview() helper at ecommercen/helpers/shopmodule_helper.php:177-184:

php
function allowSendEmailForReview(string $mail, string $actionId): bool {
    $ci =& get_instance();
    $q = $ci->db->where(['mail' => $mail, 'action_id' => $actionId])->get('shop_customer_reviews');
    return !(bool)($q and $q->num_rows());
}

The table enforces a one-shot-per-customer-per-campaign invariant — it has no bearing on the actual review moderation flow. Data sanitization references it via Adv_sanitize_model.php:8 (protected $tableReviews = 'shop_customer_reviews';).


Customer Submission

Customer-facing review submission is driven by Webrun::submit_product_review() at application/controllers/Webrun.php:32-73. The flow is strictly AJAX-only (is_ajax_request()) and reads review_rating, product_id, review_name, and review_text from POST.

php
$data['active']      = false;
$data['review_date'] = date('Y-m-d H:i:s');
$data['lang']        = $this->language_abbr;

Key characteristics:

  1. active is hard-coded to false (0) at application/controllers/Webrun.php — there is no registry-driven auto-approve switch. Every customer-submitted review enters pending state.
  2. customer_id is taken from session (session->userdata('customer_id')), so guest submissions are effectively disallowed.
  3. Dedup: insertion goes through Adv_product_reviews_model::safeAddReview() (ecommercen/eshop/models/Adv_product_reviews_model.php:264-272), which calls isProductReviewedByCustomer($productId, $customerId) (:364-372). A customer can have at most one review per product, regardless of status.
  4. No captcha, no rate limiting, no purchase verification. A logged-in customer can submit a review for any product they have never reviewed before, whether or not they ever bought it.
  5. On success, the rating is forwarded to the AI rating endpoint via $this->ratingToAdvisableAI($data['product_id'], $data['star_points']) (application/controllers/Webrun.php:66).
  6. The product page short-circuits the review form when the customer has already submitted once: ecommercen/eshop/controllers/Adv_products.php:406 sets $this->render['allowProductReview'] = !$this->product_reviews_model->isProductReviewedByCustomer(...).

See also CF-16 Product Reviews for the storefront-side flow.


Code Flow

Listing Reviews

  1. Admin navigates to product_reviews_admin.
  2. Adv_product_reviews_admin::index() (ecommercen/eshop/controllers/Adv_product_reviews_admin.php:37-72) defaults to conditions = ['commentStatus' => 'pending'].
  3. If a search form is submitted, the session stores the conditions under prdSearch; otherwise the existing session value is reused.
  4. Conditions are translated by Adv_product_reviews_model::fixAdminSearch() (ecommercen/eshop/models/Adv_product_reviews_model.php:97-135), which maps UI filter values: 'true' → '1' (approved), 'false' → '2' (rejected), 'pending' → '0'.
  5. getProductReviewsAdmin() (:29-34) returns ['results', 'numRows'] via getProductReviewsAdminResults() (:46-79) and getProductReviewsAdminNumRows() (:81-94).
  6. The joined query hits shop_product_reviews, shop_product_mui, and shop_customer, filtered by the admin's current languageAbbr — reviews in other languages are not visible from the current language context.

Single Review Status Change

text
Adv_product_reviews_admin::setStatus($reviewId, $status)   // :130-152
  |
  +--> checkAndSendEmail($reviewId, $status)               // :154-164
  |      +--> getCustomerEmail($reviewId)
  |      +--> getProductMailStatus($reviewId)
  |      +--> if is_email_sent == 0:
  |             +--> adv_mailer::sendUserReviewStatusUpdateEmail(...)
  |             +--> setReviewEmailStatus($reviewId, 1)
  |
  +--> product_reviews_model->setReviewStatus($reviewId, $status)   // :233-238
  |
  +--> if registry POINT_SYSTEM/IS_ENABLED AND $status == 1:
  |      +--> load library('loyalty')
  |      +--> if registry ECOMMERCEN_PLUS/CUSTOMER_REVIEW_REWARD_ENABLE:
  |             +--> $rewardValue = registry ECOMMERCEN_PLUS/CUSTOMER_REVIEW_REWARD
  |             +--> loyalty->savePointsToCustomer($customerData->id, $rewardValue)
  |
  +--> redirect to product_reviews_admin

Exact loyalty branch (ecommercen/eshop/controllers/Adv_product_reviews_admin.php:130-152):

php
if ($this->registry->value('POINT_SYSTEM', 'IS_ENABLED') && $status == 1) {
    $this->load->library('loyalty');
    if (!empty($this->registry->value('ECOMMERCEN_PLUS', 'CUSTOMER_REVIEW_REWARD_ENABLE'))){
        $rewardValue = $this->registry->value('ECOMMERCEN_PLUS','CUSTOMER_REVIEW_REWARD');
        $this->loyalty->savePointsToCustomer($customerData->id, $rewardValue);
    }
}

Bulk Status Change

text
Adv_product_reviews_admin::bulkSetStatus()                 // controller
  |
  +--> POST reviewIds[] and status
  +--> product_reviews_model->setReviewsStatusBatch($ids, $status)
  |
  +--> if registry POINT_SYSTEM/IS_ENABLED AND $status == 1:
  |      +--> load library('loyalty')
  |      +--> if registry ECOMMERCEN_PLUS/CUSTOMER_REVIEW_REWARD_ENABLE:
  |             +--> $rewardValue = registry ECOMMERCEN_PLUS/CUSTOMER_REVIEW_REWARD
  |             +--> $reviewCustomerIds = product_reviews_model->getCustomerIds($reviewIds)
  |             +--> for each review's customer id:
  |                    +--> loyalty->savePointsToCustomer($id, $rewardValue)
  |
  +--> getProductMailStatuses($ids)
  +--> filter to ids where is_email_sent == 0
  +--> getCustomerEmails($ids)
  +--> for each unsent: adv_mailer::sendUserReviewStatusUpdateEmail(...)
  +--> setReviewEmailStatusBatch($processedIds, 1)
  |
  +--> redirect to product_reviews_admin

Fixed in #16: bulkSetStatus() (ecommercen/eshop/controllers/Adv_product_reviews_admin.php:80-127) now mirrors setStatus()'s loyalty branch, resolving one customer id per review via the new model method getCustomerIds(array $reviewIds) (ecommercen/eshop/models/Adv_product_reviews_model.php:443-458) and calling loyalty->savePointsToCustomer() once per approved review. Bulk-approving 50 reviews now awards 50 × CUSTOMER_REVIEW_REWARD, matching single approval. The award itself remains a raw total_points + N update — see the Loyalty Integration Gap section for the still-open non-idempotency issue.

Rating Aggregation

After every review mutation that changes the active count, Adv_product_reviews_model recomputes shop_product.average_rating and shop_product.review_count by delegating to Advisable\Domains\Product\Review\RatingAggregator via di(). Four call sites and three private helpers implement this:

  1. setReviewStatus($reviewId, $status) (:233-238): looks up product_id via getProductIdForReview($reviewId) (:305-314) before the UPDATE, then calls recomputeRatingAggregates() after. Covers admin single-review moderation.

  2. setReviewsStatusBatch($reviewIds, $status) (:240-245): looks up all affected product_ids via getProductIdsForReviews($reviewIds) (:321-336) before the batch UPDATE, then recomputes each distinct product. Covers bulk moderation.

  3. addReview($data) (:274-282): recomputes for $data['product_id'] after insert. Covers customer review submission via application/controllers/Webrun.php.

  4. updateReview($reviewId, $data) (:292-300): looks up product_id before and after the update — handling product_id reassignment — and recomputes both products if they differ.

Private helpers added:

MethodLinesPurpose
getProductIdForReview($reviewId):305-314Fetch product_id for a single review
getProductIdsForReviews(array $reviewIds):321-336Fetch distinct product_ids for a batch
recomputeRatingAggregates(array $productIds):342-352Calls di()->get(RatingAggregator::class)->recomputeForProduct() for each product

Email Templates

Mailer method: adv_mailer::sendUserReviewStatusUpdateEmail($customerData, $status) at application/models/Adv_mailer.php:336-353.

Template selection uses a ternary:

php
($status == 1) ? 'productReviewAccept' : 'productReviewReject'

This means anything non-1 is treated as rejected. Resetting an already-approved review back to pending (status=0) will dispatch the "rejected" email, not a neutral or pending email. The template names map via emailViews.json to the view files application/views/main/mail/product_review_accept.php and application/views/main/mail/product_review_reject.php (runtime path used by Adv_mailer); the admin preview loads from default/mail/ due to the deprecated client_views config — see AD-53 Email Template Viewer — Two Mail Directories. Subjects are registry-driven: EMAIL_SUBJECTS / USER_REVIEW_ACCEPT and EMAIL_SUBJECTS / USER_REVIEW_REJECT. The accept template also receives loyalty_reward and product_review_reward_enable so it can mention earned points in the body.


Modern Domain Layer

A modern domain layer does exist for product reviews, contrary to earlier documentation. It lives under src/Domains/Product/Review/ and provides a full Entity + Repository + Service + Write stack. It is used by the REST controller documented below; no legacy code routes through it.

Advisable\Domains\Product\Review

FilePurpose
src/Domains/Product/Review/Repository/Entity.phpBaseEntity with $id, $product_id, $customer_id, $is_email_sent, $star_points, $nickname, $review_date, $active, $content, $lang
src/Domains/Product/Review/Repository/Repository.phpRead repository; $table = 'shop_product_reviews'
src/Domains/Product/Review/Repository/RepositoryConfigurator.phpDeclares one relation: 'product' => Relation::BELONGS_TO ProductRepository (product_id)
src/Domains/Product/Review/Repository/WriteRepository.phpWrite repository
src/Domains/Product/Review/Service.phpRead service
src/Domains/Product/Review/WriteService.phpStandard CRUD write service
src/Domains/Product/Review/WriteData.phpDTO: productId, customerId, isEmailSent, starPoints, nickname, reviewDate, active, content, lang. Required on create: productId, customerId, starPoints, reviewDate, lang. active is optional on both create and update.
src/Domains/Product/Review/Validator.phpEmpty stubvalidateForCreate() / validateForUpdate() collect empty error arrays and never throw. Contains no business rules.
src/Domains/Product/Review/ListRequest.phpFilter/sort/paginate request object used by REST

Because active is optional in WriteData and Validator is empty, the generic create()/update() path does not itself enforce "new reviews must be pending" — that remains true for admin-context writes. Customer submissions are covered separately: both the legacy admin safeAddReview() and the REST customer store() path server-force active=0 (see Modern REST Gap below).

Advisable\Domains\Customer\CustomerReview

A separate modern domain exists for the dedup log table shop_customer_reviews:

  • src/Domains/Customer/CustomerReview/Repository/Repository.php$table = 'shop_customer_reviews'
  • Uses NullRelationConfigurator because rows are identified by mail, not customer_id (noted in domain-coverage.md:83)
  • REST controller src/Rest/Customer/Controllers/CustomerReview.php exposes filters id, actionId, mail (partial)
  • REST routes at application/config/rest_routes.php:644-658
  • REST policy at application/config/rest_policies.php:584-585: backend auth with [ADMIN, MARKETING]

This domain is included here only to reinforce that shop_customer_reviews is the prompt-email dedup log, not product reviews.

Modern REST Gap

The customer storefront submission path (velora#42) closed most of what was previously a wholesale gap. Review::store() server-forces every identity/status/date field — customer_id (from the JWT), active = 0, is_email_sent = 0, review_date, and lang — and never trusts them from the request (src/Rest/Product/Controllers/Review.php:183-193). It then calls WriteService::createForCustomer() (:196), which enforces the storefront rules the generic admin create() intentionally leaves open (src/Domains/Product/Review/WriteService.php:49-72):

  • Required productId and a 1-5 starPoints rating, else ValidationException
  • One review per product per customer (repository->existsForProductAndCustomer()) — the same dedup as the legacy safeAddReview()

createForCustomer() then delegates to create() (:21-37; transactional block :26-30, aggregator recompute :32-34).

What the REST path still does not do (moderation side-effects, legacy-admin-only):

  • Does not send moderation emails on status change
  • Does not award loyalty points on approval

After creating, create() calls RatingAggregator::recomputeForProduct() whenever product_id is non-null on the returned entity (WriteService.php:32-34). The same hook fires on update() (recomputes for both the old and new product_id if changed, WriteService.php:91-98) and on delete() (recomputes for the deleted review's product_id, WriteService.php:108-110).

The remaining moderation side-effects (emails, loyalty award) live exclusively in the legacy admin controller. A client relying on REST update/destroy to moderate reviews will silently bypass those two side-effects.


Email Prompts (post-purchase) — separate flow

Not to be confused with review moderation, there is a separate "please leave us a review" prompt email job that writes to shop_customer_reviews purely for deduplication. The full flow is documented in SY-11 Review Reminders.

Key facts relevant to this flow:

  • Job registered at application/config/jobs.php:182; the cron schedule is currently commented out at application/config/jobs.php:48-50 — the job is opt-in per deployment. The schedule, when enabled, is 30 17 * * *
  • Recipient queries live at ecommercen/eshop/models/Adv_order_model.php:1954-2029
  • Notification emails sent via adv_mailer::sendCustomerPromptReview() at application/models/Adv_mailer.php:355

Cron Summary

  • There is no cron job for the moderation flow itself. Approval/rejection is purely admin-driven and synchronous: setStatus() and bulkSetStatus() execute the DB update and email dispatch inline on the HTTP request.
  • The only review-related cron is the (commented-out) post-purchase prompt email job described above.

Loyalty Integration Gap

Loyalty awards are applied by Adv_loyalty::savePointsToCustomer($customerId, $points) at ecommercen/libraries/Adv_loyalty.php:236-244:

sql
UPDATE shop_customer SET total_points = total_points + {points} WHERE id = {customerId}

One defect remains from this implementation combined with the moderation flow above (a second, the bulk-vs-single divergence, was fixed by #16 — see below):

  1. Bulk vs single divergence — fixed (#16). setStatus() applies the loyalty branch (Adv_product_reviews_admin.php:130-152) and bulkSetStatus() (:80-127) now applies the same branch, resolving one customer id per review via getCustomerIds() (Adv_product_reviews_model.php:443-458) and awarding each approved review individually. Approving 50 reviews via the bulk checkbox and approving the same 50 one-by-one both award 50 × CUSTOMER_REVIEW_REWARD.
  2. Not idempotent. The raw total_points = total_points + N update has no guard against repeated approval. A cycle of pending → approved → pending → approved double-awards points every iteration. The is_email_sent flag only guards email duplicates; it does not protect the loyalty ledger. A review can therefore accumulate arbitrarily many reward cycles — this applies equally to the single and bulk paths now that both award points.

Registry knobs:

SourceKeyDescription
RegistryPOINT_SYSTEM / IS_ENABLEDMaster switch for the loyalty point system
RegistryECOMMERCEN_PLUS / CUSTOMER_REVIEW_REWARD_ENABLEEnable point rewards for approved reviews
RegistryECOMMERCEN_PLUS / CUSTOMER_REVIEW_REWARDNumber of points awarded per approved review

Both CUSTOMER_REVIEW_REWARD_ENABLE and CUSTOMER_REVIEW_REWARD default to '0' via the seeder database/migrations/20250312151508_customer_product_review_reward.php.

Settings UI lives at ecommercen/settings/controllers/Adv_settings.php:1777-1803 (under the e_commercen_plus view). The POST field names are confusingly labeled emailsForRewardForReview and emailsForRewardForReviewValue — despite the name, they configure review-approval rewards, not "email for reward".

See also SY-10 Loyalty Points Jobs.


Architecture

ComponentPathPurpose
Adv_product_reviews_adminecommercen/eshop/controllers/Adv_product_reviews_admin.phpLegacy admin moderation controller (149 lines)
Product_reviews_adminapplication/modules/eshop/controllers/Product_reviews_admin.phpEmpty client-override subclass
Adv_product_reviews_modelecommercen/eshop/models/Adv_product_reviews_model.phpReview queries, status updates, email tracking
Product_reviews_modelapplication/modules/eshop/models/Product_reviews_model.phpEmpty client-override subclass
Webrun::submit_product_reviewapplication/controllers/Webrun.php:32-73Customer-side AJAX submission endpoint
Adv_customer_modelecommercen/eshop/models/Adv_customer_model.phpCustomer data for email dispatch
adv_mailer::sendUserReviewStatusUpdateEmailapplication/models/Adv_mailer.php:336-353Sends accept / reject email
adv_mailer::sendCustomerPromptReviewapplication/models/Adv_mailer.php:355Sends post-purchase prompt emails
Adv_loyalty::savePointsToCustomerecommercen/libraries/Adv_loyalty.php:236-244Awards loyalty points on approval
Advisable\Domains\Product\Reviewsrc/Domains/Product/Review/Modern domain (Entity/Repository/Service/Write)
Advisable\Domains\Product\Review\RatingAggregatorsrc/Domains/Product/Review/RatingAggregator.phpRecomputes shop_product.average_rating and shop_product.review_count; injected into legacy model via di()
Advisable\Rest\Product\Controllers\Reviewsrc/Rest/Product/Controllers/Review.phpModern REST controller (read + write)
Advisable\Domains\Customer\CustomerReviewsrc/Domains/Customer/CustomerReview/Dedup-log domain for shop_customer_reviews
AdvSendMailToCustomersForReviewecommercen/job/libraries/AdvSendMailToCustomersForReview.phpPrompt email job (cron commented out)
Legacy routesapplication/config/routes.php:414-419Admin controller routes
REST routes (read)application/config/rest_routes.php:534-540Modern REST GET routes
REST routes (write)application/config/rest_routes.php:1524-1530Modern REST POST/DELETE routes
REST policyapplication/config/rest_policies.php:312-319Review RBAC (guest reads, backend writes)
Admin menuapplication/config/admin_menu.php:586-592PRODUCTS group entry

Legacy Model Methods

ecommercen/eshop/models/Adv_product_reviews_model.php:

MethodLinesPurpose
getProductReviewsAdmin()29-34Returns ['results', 'numRows']
getProductMailStatus($id)36-44Fetch is_email_sent for one review
getProductReviewsAdminResults()46-79Joined query; filters by admin languageAbbr
getProductReviewsAdminNumRows()81-94COUNT query for pagination
fixAdminSearch()97-135Maps UI filters to active values
getProductReviews($productId)195-225Storefront read; only active=1
setReviewStatus($id, $status)233-238UPDATE a single review's status; recomputes rating aggregate after
setReviewsStatusBatch($ids, $status)240-245Batch UPDATE WHERE IN; recomputes rating aggregate for each distinct product after
setReviewEmailStatus($id, 1)247-250Mark single review as emailed
setReviewEmailStatusBatch($ids, 1)252-255Batch mark as emailed
getProductMailStatuses($ids)257-263Fetch is_email_sent for many ids
safeAddReview($data)264-272Insert-if-not-dup via isProductReviewedByCustomer
addReview($data)274-282Insert a review; recomputes rating aggregate after
updateReview($reviewId, $data)292-300Update a review; recomputes rating aggregate for old and new product_id
isProductReviewedByCustomer($p, $c)364-372Dedup check
getProductIdForReview($reviewId)305-314Fetch product_id for a single review (used by aggregation)
getProductIdsForReviews(array $reviewIds)321-336Fetch distinct product_ids for a batch (used by aggregation)
recomputeRatingAggregates(array $productIds)342-352Delegates to RatingAggregator::recomputeForProduct() via di()
getCustomerReviews($customerId)374-397Customer "My Reviews"; only active=1
getCustomerEmail($reviewId)404-416Resolve recipient for single review email
getCustomerEmails($reviewIds)417-431Resolve recipients for bulk email
getCustomerIds(array $reviewIds)443-458Resolve one customer id per review (used by bulk loyalty award)
showReviewsForGoogle()460-479Requires at least 50 approved reviews before exporting
getReviewsForGoogle()481-506Google Merchant feed export (only active=1)

Configuration

SourceKeyDescription
RegistryPOINT_SYSTEM / IS_ENABLEDMaster switch for the loyalty point system
RegistryECOMMERCEN_PLUS / CUSTOMER_REVIEW_REWARD_ENABLEEnable point rewards for approved reviews
RegistryECOMMERCEN_PLUS / CUSTOMER_REVIEW_REWARDNumber of points awarded per approved review
RegistryEMAIL_SUBJECTS / USER_REVIEW_ACCEPTSubject for the approved-review email
RegistryEMAIL_SUBJECTS / USER_REVIEW_REJECTSubject for the rejected-review email
RegistryEMAIL_SUBJECTS / REVIEW_FOR_FACEBOOKSubject for the Facebook prompt email
RegistryEMAIL_SUBJECTS / REVIEW_FOR_SKROUTZSubject for the Skroutz prompt email
RegistryEMAIL_SUBJECTS / REVIEW_FOR_GOOGLESubject for the Google prompt email
Configapplication/config/app.php:104-118Prompt campaign keys (reviewForFacebook/Skroutz/Google)
Configapplication/config/jobs.php:48-50Prompt email cron schedule (commented out by default)
Seederdatabase/migrations/20250312151508_customer_product_review_reward.phpDefault registry values (both '0')

Required roles (legacy admin): AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING (checked at ecommercen/eshop/controllers/Adv_product_reviews_admin.php:20-26). Note that AUTH_ROLE_PRODUCTS is not in the legacy allow-list despite the menu placing the entry in the PRODUCTS group.

Required roles (REST writes): AUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING (declared at application/config/rest_policies.php:317-324). Aligned with the legacy controller's allow-list per #56.


Client Extension Points

  • Override admin controller: In a client repo, extend application/modules/eshop/controllers/Product_reviews_admin.php (empty subclass already in place).
  • Override legacy model: Extend application/modules/eshop/models/Product_reviews_model.php (empty subclass already in place).
  • Override modern domain: In a client repo, create Custom\Domains\Product\Review\... classes and register DI aliases for Advisable\Domains\Product\Review\... in custom/Domains/container.php.
  • Custom email templates: Override product_review_accept.php and product_review_reject.php in the client view set (application/views/{default,main}/mail/).
  • Override prompt email job: Extend application/modules/job/libraries/SendMailToCustomersForReview.php (empty subclass already in place).

Business Rules

  1. Pending by default. Customer submissions hard-code active=false/0. There is no registry-driven auto-approve. (application/controllers/Webrun.php:32-73)
  2. At most one review per product per customer. Enforced by isProductReviewedByCustomer() and the insert-if-not-dup safeAddReview().
  3. Tri-state status. active is 0=pending, 1=approved, 2=rejected. Storefront and feed queries filter to active=1 only.
  4. Email once per review. is_email_sent guards duplicate notifications. Resetting an approved review back to pending will send the "rejected" email because the template selector treats anything non-1 as rejected (application/models/Adv_mailer.php:336-353).
  5. Bulk and single approval award loyalty identically (fixed #16). Both setStatus() and bulkSetStatus() apply the loyalty branch; bulk resolves one customer id per review via getCustomerIds() and awards each approved review separately. Points are still applied as a raw total_points + N, so repeated approve/pending cycles double-award on either path — see Known Issue #7.
  6. Language-scoped admin listing. getProductReviewsAdminResults() joins on languageAbbr, so reviews in other languages are invisible from the current admin language context.
  7. REST reads are public; write RBAC matches the legacy admin. index/show/item are guest in policy; store is customer (storefront submission); update/destroy require ADMIN/MARKETING — the same roles as the legacy admin (AUTH_ROLE_PRODUCTS is not used here).
  8. REST moderation bypasses the notification side-effects. Customer submissions via REST store() do get pending-by-default (active=0) and one-per-product dedup, but approving/rejecting through REST update sends no moderation email and awards no loyalty points — clients must not use REST to approve reviews if they need those side-effects.

Known Issues & Security Gaps

The following gaps are all present in the current codebase. Cite the linked location when addressing them.

  1. active is tri-state, not boolean. Any client assuming active is a 0/1 boolean will mishandle rejected reviews. Confirmed by the inline DDL comment in database/initial/initial.sql:1801-1822.
  2. No _mui companion table. Multi-language is stored per-row via the lang column. Admin listing is scoped by the admin's current language only (ecommercen/eshop/models/Adv_product_reviews_model.php:46-79).
  3. shop_customer_reviews is NOT product reviews. It is the dedup log for post-purchase prompt emails (database/initial/initial.sql:1209-1217, ecommercen/helpers/shopmodule_helper.php:177-184). Any code assuming otherwise is wrong.
  4. Modern domain layer is write-capable, but moderation is legacy-only. src/Domains/Product/Review/ has full CQRS (Service, WriteService, Validator, Repository, WriteRepository) and the REST controller src/Rest/Product/Controllers/Review.php uses HandlesWriteActions. Customer submission via REST store() now applies pending-by-default (active=0), server-forced identity/date fields, a 1-5 star check, and one-review-per-product dedup (velora#42). What the modern path still does NOT do: notification emails on status change, loyalty-point award. Those two side-effects still live exclusively in Adv_product_reviews_admin.
  5. REST RBAC alignment with legacy (resolved by #56). Both layers now require ADMIN/MARKETING: legacy at ecommercen/eshop/controllers/Adv_product_reviews_admin.php:20-26, REST at application/config/rest_policies.php:317-324. A regression guard pins the resolved policy in tests/Unit/Rest/Middleware/PolicyResolverIntegrationTest.php so any future drift fails CI.
  6. Bulk vs single loyalty gap (resolved by #16). bulkSetStatus() (ecommercen/eshop/controllers/Adv_product_reviews_admin.php:80-127) now applies the same loyalty branch as setStatus() (:130-152), resolving one customer id per review via the new getCustomerIds() model method (ecommercen/eshop/models/Adv_product_reviews_model.php:443-458) and awarding each approved review individually. The award remains non-idempotent on both paths — see Known Issue #7 below, unaffected by this fix.
  7. Loyalty award is not idempotent. Adv_loyalty::savePointsToCustomer runs a raw total_points = total_points + N (ecommercen/libraries/Adv_loyalty.php:236-244). pending → approved → pending → approved cycles accumulate. is_email_sent only guards emails, not points.
  8. Customer submission has no captcha, no rate limiting, no purchase verification. Webrun::submit_product_review() (application/controllers/Webrun.php:32-73) only requires a logged-in customer_id and an un-reviewed product.
  9. Prompt email cron is commented out by default. application/config/jobs.php:48-50 ships with the schedule disabled; the 4-day delivery window is hard-coded into the SQL queries in Adv_order_model.php:1954-2029.
  10. Email template selector treats any non-1 status as "rejected". Including status=0 (pending). application/models/Adv_mailer.php:336-353.
  11. Legacy admin allow-list is missing AUTH_ROLE_PRODUCTS despite the menu entry living under the PRODUCTS group. ecommercen/eshop/controllers/Adv_product_reviews_admin.php:20-26 vs application/config/admin_menu.php:586-592.
  12. Settings UI POST fields are misleadingly named. The field names emailsForRewardForReview / emailsForRewardForReviewValue at ecommercen/settings/controllers/Adv_settings.php:1777-1803 actually configure review-approval rewards, not any kind of "email-for-reward" feature.

Tests

Integration tests cover the modern domain and REST layer only — there are no automated tests for the legacy moderation controller or the loyalty side-effects.

  • tests/Integration/Domains/Product/Review/RepositoryTest.php
  • tests/Integration/Domains/Product/Review/ServiceTest.php
  • tests/Integration/Domains/Customer/CustomerReview/RepositoryTest.php
  • tests/Integration/Domains/Customer/CustomerReview/ServiceTest.php