Skip to content

Product Review Moderation

Flow ID: AD-52 | Module(s): eshop | Complexity: Medium Last Updated: 2026-05-07

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. Note that this reward is only granted on the single-review approval path — bulk approval intentionally (or unintentionally) skips loyalty awards entirely.

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:35). The REST writer fires RatingAggregator to keep shop_product.average_rating and review_count current (#174), but does not apply the legacy admin's moderation side-effects: no pending-by-default, no approval/rejection notification emails, no loyalty-point award. All moderation business rules 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/reviewstorebackendAUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING
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:534-540. Write routes: application/config/rest_routes.php:1524-1530. Policy: application/config/rest_policies.php:312-319.

RBAC divergence (comment flagged at application/config/rest_policies.php:584-585): the legacy admin controller requires ADMIN/MARKETING while the REST controller requires ADMIN/PRODUCTS. A user with only the MARKETING role can moderate via the legacy admin but cannot touch the REST write endpoints; a user with only the PRODUCTS role can write via REST but is denied by the legacy controller.

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)   // :114-136
  |
  +--> checkAndSendEmail($reviewId, $status)               // :138-148
  |      +--> 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:114-136):

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()                 // :80-111
  |
  +--> POST reviewIds[] and status
  +--> product_reviews_model->setReviewsStatusBatch($ids, $status)   // :240-245
  |
  +--> getProductMailStatuses($ids)                        // :257-263
  +--> filter to ids where is_email_sent == 0
  +--> getCustomerEmails($ids)                             // :352-366
  +--> for each unsent: adv_mailer::sendUserReviewStatusUpdateEmail(...)
  +--> setReviewEmailStatusBatch($processedIds, 1)         // :252-255
  |
  +--> redirect to product_reviews_admin

bulkSetStatus() contains zero loyalty references. Bulk-approving 50 reviews awards 0 points; approving the same 50 reviews one-by-one awards 50 × CUSTOMER_REVIEW_REWARD. See the Loyalty Integration Gap section.

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 domain layer does not enforce "new reviews must be pending". The legacy admin is the only place that forces active=0 on customer submissions.

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

REST store() goes through WriteService::create()writeRepository->insert($writeData->toArray()) (src/Domains/Product/Review/WriteService.php:20-36; transactional block at :25-29, aggregator recompute at :31-33). It does none of the following:

  • Does not force active=0 on create
  • Does not send moderation emails on status change
  • Does not award loyalty points on approval
  • Does not set or respect is_email_sent

After creating, it calls RatingAggregator::recomputeForProduct() whenever product_id is non-null on the returned entity (src/Domains/Product/Review/WriteService.php:31-33). The same hook fires on update() (recomputes for both the old and new product_id if changed, WriteService.php:55-62) and on delete() (recomputes for the deleted review's product_id, WriteService.php:71-74).

All moderation side-effects live exclusively in the legacy admin controller. A client relying on REST to moderate reviews will silently bypass every business rule documented above.


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}

Two distinct defects follow from this implementation combined with the moderation flow above:

  1. Bulk vs single divergence. setStatus() applies the loyalty branch (Adv_product_reviews_admin.php:114-136). bulkSetStatus() (:80-111) contains zero loyalty references. Approving 50 reviews via the bulk checkbox awards 0 points; approving the same 50 one-by-one awards 50 × CUSTOMER_REVIEW_REWARD. Admins unaware of this will unintentionally deny rewards whenever they use the bulk UI.
  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.

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
showReviewsForGoogle()433-452Requires at least 50 approved reviews before exporting
getReviewsForGoogle()454-479Google 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. Single-approval loyalty only. Only setStatus() applies the loyalty branch. bulkSetStatus() silently skips points. Points are applied as a raw total_points + N, so repeated approve/pending cycles double-award.
  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. index/show/item are guest in policy; REST writes require ADMIN/PRODUCTS. Legacy admin requires ADMIN/MARKETING.
  8. REST writes bypass moderation side-effects. No pending default, no emails, no loyalty — 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. What the modern path does NOT do: pending-by-default on customer submission, notification emails on status change, loyalty-point award. Those 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. bulkSetStatus() (ecommercen/eshop/controllers/Adv_product_reviews_admin.php:80-111) has no loyalty code. setStatus() (:114-136) does. Consistent review reward behavior is impossible without picking one path.
  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