Appearance
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.
| 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 | customer | — (customer-scoped storefront submission) |
| 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: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
| 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) // :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_adminExact 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_adminFixed 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:
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 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
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
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
productIdand a 1-5starPointsrating, elseValidationException - One review per product per customer (
repository->existsForProductAndCustomer()) — the same dedup as the legacysafeAddReview()
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 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}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):
- Bulk vs single divergence — fixed (#16).
setStatus()applies the loyalty branch (Adv_product_reviews_admin.php:130-152) andbulkSetStatus()(:80-127) now applies the same branch, resolving one customer id per review viagetCustomerIds()(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 award50 × CUSTOMER_REVIEW_REWARD. - 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 — this applies equally to the single and bulk paths now that both award points.
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 |
getCustomerIds(array $reviewIds) | 443-458 | Resolve one customer id per review (used by bulk loyalty award) |
showReviewsForGoogle() | 460-479 | Requires at least 50 approved reviews before exporting |
getReviewsForGoogle() | 481-506 | 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). - Bulk and single approval award loyalty identically (fixed #16). Both
setStatus()andbulkSetStatus()apply the loyalty branch; bulk resolves one customer id per review viagetCustomerIds()and awards each approved review separately. Points are still applied as a rawtotal_points + N, so repeated approve/pending cycles double-award on either path — see Known Issue #7. - Language-scoped admin listing.
getProductReviewsAdminResults()joins onlanguageAbbr, so reviews in other languages are invisible from the current admin language context. - REST reads are public; write RBAC matches the legacy admin.
index/show/itemareguestin policy;storeiscustomer(storefront submission);update/destroyrequireADMIN/MARKETING— the same roles as the legacy admin (AUTH_ROLE_PRODUCTSis not used here). - 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 RESTupdatesends 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.
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. Customer submission via RESTstore()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 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 (resolved by #16).
bulkSetStatus()(ecommercen/eshop/controllers/Adv_product_reviews_admin.php:80-127) now applies the same loyalty branch assetStatus()(:130-152), resolving one customer id per review via the newgetCustomerIds()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. - 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