Appearance
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.
| Method | Path | Action | Auth | Roles |
|---|---|---|---|---|
| GET | /rest/product/review | index | guest | — |
| GET | /rest/product/review/{id} | show | guest | — |
| GET | /rest/product/review/item | item | guest | — |
| POST | /rest/product/review | store | backend | AUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING |
| POST | /rest/product/review/{id} | update | backend | AUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING |
| DELETE | /rest/product/review/{id} | destroy | backend | AUTH_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
| Route | Controller | Method | HTTP | Description |
|---|---|---|---|---|
eshop/product_reviews_admin | Adv_product_reviews_admin | index() | GET | List reviews (default filter: pending) |
eshop/product_reviews_admin/{offset} | Adv_product_reviews_admin | index($offset) | GET | Paginated review list |
eshop/product_reviews_admin/resetIndex | Adv_product_reviews_admin | resetIndex() | GET | Clear the prdSearch session key |
eshop/product_reviews_admin/bulkSetStatus | Adv_product_reviews_admin | bulkSetStatus() | POST | Batch approve/reject multiple reviews |
eshop/product_reviews_admin/setStatus/{id}/{status} | Adv_product_reviews_admin | setStatus($id, $status) | GET | Approve, 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
| Route | Controller | Method | HTTP | Description |
|---|---|---|---|---|
webrun/submit_product_review | Webrun | submit_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.
| Column | Type | Notes |
|---|---|---|
id | INT | Primary key |
product_id | INT NOT NULL | FK to shop_product |
customer_id | INT NOT NULL | FK to shop_customer |
is_email_sent | TINYINT(1) DEFAULT 0 | 1 once the status-change email has been dispatched |
star_points | TINYINT(1) NOT NULL | 1-5 |
nickname | VARCHAR(150) NULL | Display name (falls back to customer record) |
review_date | DATETIME NOT NULL | Submission timestamp |
active | TINYINT(1) DEFAULT 0 | Tri-state: 0=pending, 1=approved, 2=rejected (confirmed by inline DDL comment) |
content | MEDIUMTEXT NULL | Review body |
lang | VARCHAR(2) NOT NULL | Language 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.
| Column | Type | Notes |
|---|---|---|
id | — | Primary key |
action_id | INT | Campaign id (1=Facebook, 2=Skroutz, 3=Google) |
mail | VARCHAR(250) | Recipient email |
entry_date | DATETIME | When 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:
activeis hard-coded tofalse(0) atapplication/controllers/Webrun.php— there is no registry-driven auto-approve switch. Every customer-submitted review enters pending state.customer_idis taken from session (session->userdata('customer_id')), so guest submissions are effectively disallowed.- Dedup: insertion goes through
Adv_product_reviews_model::safeAddReview()(ecommercen/eshop/models/Adv_product_reviews_model.php:264-272), which callsisProductReviewedByCustomer($productId, $customerId)(:364-372). A customer can have at most one review per product, regardless of status. - 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.
- 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). - The product page short-circuits the review form when the customer has already submitted once:
ecommercen/eshop/controllers/Adv_products.php:406sets$this->render['allowProductReview'] = !$this->product_reviews_model->isProductReviewedByCustomer(...).
See also CF-16 Product Reviews for the storefront-side flow.
Code Flow
Listing Reviews
- Admin navigates to
product_reviews_admin. Adv_product_reviews_admin::index()(ecommercen/eshop/controllers/Adv_product_reviews_admin.php:37-72) defaults toconditions = ['commentStatus' => 'pending'].- If a search form is submitted, the session stores the conditions under
prdSearch; otherwise the existing session value is reused. - 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'. getProductReviewsAdmin()(:29-34) returns['results', 'numRows']viagetProductReviewsAdminResults()(:46-79) andgetProductReviewsAdminNumRows()(:81-94).- The joined query hits
shop_product_reviews,shop_product_mui, andshop_customer, filtered by the admin's currentlanguageAbbr— 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_adminExact 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_adminbulkSetStatus() 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:
setReviewStatus($reviewId, $status)(:233-238): looks upproduct_idviagetProductIdForReview($reviewId)(:305-314) before the UPDATE, then callsrecomputeRatingAggregates()after. Covers admin single-review moderation.setReviewsStatusBatch($reviewIds, $status)(:240-245): looks up all affectedproduct_ids viagetProductIdsForReviews($reviewIds)(:321-336) before the batch UPDATE, then recomputes each distinct product. Covers bulk moderation.addReview($data)(:274-282): recomputes for$data['product_id']after insert. Covers customer review submission viaapplication/controllers/Webrun.php.updateReview($reviewId, $data)(:292-300): looks upproduct_idbefore and after the update — handling product_id reassignment — and recomputes both products if they differ.
Private helpers added:
| Method | Lines | Purpose |
|---|---|---|
getProductIdForReview($reviewId) | :305-314 | Fetch product_id for a single review |
getProductIdsForReviews(array $reviewIds) | :321-336 | Fetch distinct product_ids for a batch |
recomputeRatingAggregates(array $productIds) | :342-352 | Calls 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
| File | Purpose |
|---|---|
src/Domains/Product/Review/Repository/Entity.php | BaseEntity with $id, $product_id, $customer_id, $is_email_sent, $star_points, $nickname, $review_date, $active, $content, $lang |
src/Domains/Product/Review/Repository/Repository.php | Read repository; $table = 'shop_product_reviews' |
src/Domains/Product/Review/Repository/RepositoryConfigurator.php | Declares one relation: 'product' => Relation::BELONGS_TO ProductRepository (product_id) |
src/Domains/Product/Review/Repository/WriteRepository.php | Write repository |
src/Domains/Product/Review/Service.php | Read service |
src/Domains/Product/Review/WriteService.php | Standard CRUD write service |
src/Domains/Product/Review/WriteData.php | DTO: 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.php | Empty stub — validateForCreate() / validateForUpdate() collect empty error arrays and never throw. Contains no business rules. |
src/Domains/Product/Review/ListRequest.php | Filter/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
NullRelationConfiguratorbecause rows are identified bymail, notcustomer_id(noted indomain-coverage.md:83) - REST controller
src/Rest/Customer/Controllers/CustomerReview.phpexposes filtersid,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=0on 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 atapplication/config/jobs.php:48-50— the job is opt-in per deployment. The schedule, when enabled, is30 17 * * * - Recipient queries live at
ecommercen/eshop/models/Adv_order_model.php:1954-2029 - Notification emails sent via
adv_mailer::sendCustomerPromptReview()atapplication/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()andbulkSetStatus()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:
- 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 awards50 × CUSTOMER_REVIEW_REWARD. Admins unaware of this will unintentionally deny rewards whenever they use the bulk UI. - Not idempotent. The raw
total_points = total_points + Nupdate has no guard against repeated approval. A cycle ofpending → approved → pending → approveddouble-awards points every iteration. Theis_email_sentflag only guards email duplicates; it does not protect the loyalty ledger. A review can therefore accumulate arbitrarily many reward cycles.
Registry knobs:
| Source | Key | Description |
|---|---|---|
| Registry | POINT_SYSTEM / IS_ENABLED | Master switch for the loyalty point system |
| Registry | ECOMMERCEN_PLUS / CUSTOMER_REVIEW_REWARD_ENABLE | Enable point rewards for approved reviews |
| Registry | ECOMMERCEN_PLUS / CUSTOMER_REVIEW_REWARD | Number 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
| Component | Path | Purpose |
|---|---|---|
Adv_product_reviews_admin | ecommercen/eshop/controllers/Adv_product_reviews_admin.php | Legacy admin moderation controller (149 lines) |
Product_reviews_admin | application/modules/eshop/controllers/Product_reviews_admin.php | Empty client-override subclass |
Adv_product_reviews_model | ecommercen/eshop/models/Adv_product_reviews_model.php | Review queries, status updates, email tracking |
Product_reviews_model | application/modules/eshop/models/Product_reviews_model.php | Empty client-override subclass |
Webrun::submit_product_review | application/controllers/Webrun.php:32-73 | Customer-side AJAX submission endpoint |
Adv_customer_model | ecommercen/eshop/models/Adv_customer_model.php | Customer data for email dispatch |
adv_mailer::sendUserReviewStatusUpdateEmail | application/models/Adv_mailer.php:336-353 | Sends accept / reject email |
adv_mailer::sendCustomerPromptReview | application/models/Adv_mailer.php:355 | Sends post-purchase prompt emails |
Adv_loyalty::savePointsToCustomer | ecommercen/libraries/Adv_loyalty.php:236-244 | Awards loyalty points on approval |
Advisable\Domains\Product\Review | src/Domains/Product/Review/ | Modern domain (Entity/Repository/Service/Write) |
Advisable\Domains\Product\Review\RatingAggregator | src/Domains/Product/Review/RatingAggregator.php | Recomputes shop_product.average_rating and shop_product.review_count; injected into legacy model via di() |
Advisable\Rest\Product\Controllers\Review | src/Rest/Product/Controllers/Review.php | Modern REST controller (read + write) |
Advisable\Domains\Customer\CustomerReview | src/Domains/Customer/CustomerReview/ | Dedup-log domain for shop_customer_reviews |
AdvSendMailToCustomersForReview | ecommercen/job/libraries/AdvSendMailToCustomersForReview.php | Prompt email job (cron commented out) |
| Legacy routes | application/config/routes.php:414-419 | Admin controller routes |
| REST routes (read) | application/config/rest_routes.php:534-540 | Modern REST GET routes |
| REST routes (write) | application/config/rest_routes.php:1524-1530 | Modern REST POST/DELETE routes |
| REST policy | application/config/rest_policies.php:312-319 | Review RBAC (guest reads, backend writes) |
| Admin menu | application/config/admin_menu.php:586-592 | PRODUCTS group entry |
Legacy Model Methods
ecommercen/eshop/models/Adv_product_reviews_model.php:
| Method | Lines | Purpose |
|---|---|---|
getProductReviewsAdmin() | 29-34 | Returns ['results', 'numRows'] |
getProductMailStatus($id) | 36-44 | Fetch is_email_sent for one review |
getProductReviewsAdminResults() | 46-79 | Joined query; filters by admin languageAbbr |
getProductReviewsAdminNumRows() | 81-94 | COUNT query for pagination |
fixAdminSearch() | 97-135 | Maps UI filters to active values |
getProductReviews($productId) | 195-225 | Storefront read; only active=1 |
setReviewStatus($id, $status) | 233-238 | UPDATE a single review's status; recomputes rating aggregate after |
setReviewsStatusBatch($ids, $status) | 240-245 | Batch UPDATE WHERE IN; recomputes rating aggregate for each distinct product after |
setReviewEmailStatus($id, 1) | 247-250 | Mark single review as emailed |
setReviewEmailStatusBatch($ids, 1) | 252-255 | Batch mark as emailed |
getProductMailStatuses($ids) | 257-263 | Fetch is_email_sent for many ids |
safeAddReview($data) | 264-272 | Insert-if-not-dup via isProductReviewedByCustomer |
addReview($data) | 274-282 | Insert a review; recomputes rating aggregate after |
updateReview($reviewId, $data) | 292-300 | Update a review; recomputes rating aggregate for old and new product_id |
isProductReviewedByCustomer($p, $c) | 364-372 | Dedup check |
getProductIdForReview($reviewId) | 305-314 | Fetch product_id for a single review (used by aggregation) |
getProductIdsForReviews(array $reviewIds) | 321-336 | Fetch distinct product_ids for a batch (used by aggregation) |
recomputeRatingAggregates(array $productIds) | 342-352 | Delegates to RatingAggregator::recomputeForProduct() via di() |
getCustomerReviews($customerId) | 374-397 | Customer "My Reviews"; only active=1 |
getCustomerEmail($reviewId) | 404-416 | Resolve recipient for single review email |
getCustomerEmails($reviewIds) | 417-431 | Resolve recipients for bulk email |
showReviewsForGoogle() | 433-452 | Requires at least 50 approved reviews before exporting |
getReviewsForGoogle() | 454-479 | Google Merchant feed export (only active=1) |
Configuration
| Source | Key | Description |
|---|---|---|
| Registry | POINT_SYSTEM / IS_ENABLED | Master switch for the loyalty point system |
| Registry | ECOMMERCEN_PLUS / CUSTOMER_REVIEW_REWARD_ENABLE | Enable point rewards for approved reviews |
| Registry | ECOMMERCEN_PLUS / CUSTOMER_REVIEW_REWARD | Number of points awarded per approved review |
| Registry | EMAIL_SUBJECTS / USER_REVIEW_ACCEPT | Subject for the approved-review email |
| Registry | EMAIL_SUBJECTS / USER_REVIEW_REJECT | Subject for the rejected-review email |
| Registry | EMAIL_SUBJECTS / REVIEW_FOR_FACEBOOK | Subject for the Facebook prompt email |
| Registry | EMAIL_SUBJECTS / REVIEW_FOR_SKROUTZ | Subject for the Skroutz prompt email |
| Registry | EMAIL_SUBJECTS / REVIEW_FOR_GOOGLE | Subject for the Google prompt email |
| Config | application/config/app.php:104-118 | Prompt campaign keys (reviewForFacebook/Skroutz/Google) |
| Config | application/config/jobs.php:48-50 | Prompt email cron schedule (commented out by default) |
| Seeder | database/migrations/20250312151508_customer_product_review_reward.php | Default 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 forAdvisable\Domains\Product\Review\...incustom/Domains/container.php. - Custom email templates: Override
product_review_accept.phpandproduct_review_reject.phpin 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
- Pending by default. Customer submissions hard-code
active=false/0. There is no registry-driven auto-approve. (application/controllers/Webrun.php:32-73) - At most one review per product per customer. Enforced by
isProductReviewedByCustomer()and the insert-if-not-dupsafeAddReview(). - Tri-state status.
activeis0=pending,1=approved,2=rejected. Storefront and feed queries filter toactive=1only. - Email once per review.
is_email_sentguards 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). - Single-approval loyalty only. Only
setStatus()applies the loyalty branch.bulkSetStatus()silently skips points. Points are applied as a rawtotal_points + N, so repeated approve/pending cycles double-award. - Language-scoped admin listing.
getProductReviewsAdminResults()joins onlanguageAbbr, so reviews in other languages are invisible from the current admin language context. - REST reads are public.
index/show/itemareguestin policy; REST writes requireADMIN/PRODUCTS. Legacy admin requiresADMIN/MARKETING. - 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.
activeis tri-state, not boolean. Any client assumingactiveis a 0/1 boolean will mishandle rejected reviews. Confirmed by the inline DDL comment indatabase/initial/initial.sql:1801-1822.- No
_muicompanion table. Multi-language is stored per-row via thelangcolumn. Admin listing is scoped by the admin's current language only (ecommercen/eshop/models/Adv_product_reviews_model.php:46-79). shop_customer_reviewsis 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.- 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 controllersrc/Rest/Product/Controllers/Review.phpusesHandlesWriteActions. 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 inAdv_product_reviews_admin. - REST RBAC alignment with legacy (resolved by #56). Both layers now require
ADMIN/MARKETING: legacy atecommercen/eshop/controllers/Adv_product_reviews_admin.php:20-26, REST atapplication/config/rest_policies.php:317-324. A regression guard pins the resolved policy intests/Unit/Rest/Middleware/PolicyResolverIntegrationTest.phpso any future drift fails CI. - 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. - Loyalty award is not idempotent.
Adv_loyalty::savePointsToCustomerruns a rawtotal_points = total_points + N(ecommercen/libraries/Adv_loyalty.php:236-244).pending → approved → pending → approvedcycles accumulate.is_email_sentonly guards emails, not points. - 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-incustomer_idand an un-reviewed product. - Prompt email cron is commented out by default.
application/config/jobs.php:48-50ships with the schedule disabled; the 4-day delivery window is hard-coded into the SQL queries inAdv_order_model.php:1954-2029. - Email template selector treats any non-1 status as "rejected". Including
status=0(pending).application/models/Adv_mailer.php:336-353. - Legacy admin allow-list is missing
AUTH_ROLE_PRODUCTSdespite the menu entry living under the PRODUCTS group.ecommercen/eshop/controllers/Adv_product_reviews_admin.php:20-26vsapplication/config/admin_menu.php:586-592. - Settings UI POST fields are misleadingly named. The field names
emailsForRewardForReview/emailsForRewardForReviewValueatecommercen/settings/controllers/Adv_settings.php:1777-1803actually 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.phptests/Integration/Domains/Product/Review/ServiceTest.phptests/Integration/Domains/Customer/CustomerReview/RepositoryTest.phptests/Integration/Domains/Customer/CustomerReview/ServiceTest.php
Related Flows
- AD-02 Product Management Admin — Product detail pages surface review counts and average rating
- CF-16 Product Reviews — Storefront review submission via
Webrun::submit_product_review - SY-10 Loyalty Points Jobs — Point system that rewards reviewers on single-approval
- SY-11 Review Reminders — Post-purchase prompt emails that write to
shop_customer_reviews - SY-24 Email Dispatch System —
adv_mailerdispatchesproduct_review_accept/product_review_rejecton status change