Appearance
Product Review Moderation
Flow ID: AD-52 | Module(s): eshop | Complexity: Medium Last Updated: 2026-09-29 — 4.124.0 resync (citation drift, empty-day guard note); previously 2026-09-14 — develop resync: re-anchored citations drifted since 4.122.0 by unrelated line shifts across
rest_policies.php(newProductCode/ActiveGift/CustomMetaTaguseimports and a newActiveGift::classpolicy block),rest_routes.php(newActiveGiftGET route block),routes.php(blog-comment-admin routing fix), andAdv_product_reviews_model.php(getProductReviews()docblock expansion) andAdv_order_model.php(gift-stock restore method insertions); also correctedapplication/models/Adv_mailer.php:336-353→:393-410(Business Rules item 4, previously wrong before this diff) and two internally-inconsistentgetCustomerIds()/safeAddReview()citations to match the doc's own Legacy Model Methods table; a follow-up pass the same day corrected the Customer Submission section'sisProductReviewedByCustomer()citation from:364-372(which is actuallygetProductIdsForReviews()) to:412-420to match the doc's own Legacy Model Methods table entry; previously: 2026-08-26 — 4.121.0 resync: re-anchored citations drifted by #637 (RepositoryConfigurator.phpgained avisibilityScopeconstructor dependency on itsproductrelation) and other unrelated line shifts acrossrest_policies.php,rest_routes.php,app.php,Adv_products.php,Adv_order_model.php, andAdv_settings.php; added Known Issue #17 (owning review row still unscoped forwith=product, tracked under #624/#625/#626/#627); previously: 2026-08-12 — #59: grantedAUTH_ROLE_PRODUCTSreview-moderation access on both the legacy admin controller and the REST policy layer — a deliberate, product-owner-approved access widening that supersedes #56's earlier MARKETING-only alignment; previously: #19 doc correction: dropped the stale "admin preview loads fromdefault/mail" assertion (see AD-53)
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 award points identically (fixed in #16) — bulk resolves one customer id per review via getCustomerIds() and awards each approved review separately. The award is now idempotent per review, and genuinely so under concurrency: a new is_points_awarded flag on shop_product_reviews (mirroring is_email_sent) is atomically claimed via claimReviewPointsAward() before either path awards, so a pending → approved → pending → approved cycle — or two overlapping approvals of the same review — awards points at most once per review (fixed in #17 — 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, AUTH_ROLE_PRODUCTS |
| DELETE | /rest/product/review/{id} | destroy | backend | AUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING, AUTH_ROLE_PRODUCTS |
Read routes: application/config/rest_routes.php:573-578. Write routes: application/config/rest_routes.php:1627-1632. Policy: application/config/rest_policies.php:365-376 (defaults at :366).
RBAC is aligned with the legacy admin (comment at application/config/rest_policies.php:360-364): the Review::class policy defaults are [AUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING, AUTH_ROLE_PRODUCTS] — deliberately matching the legacy admin controller, so a PRODUCTS-only or MARKETING-only user has the same moderation surface on both layers. store is overridden to 'auth' => 'customer' (:374) for storefront submission (velora#42); only update/destroy inherit the backend + [ADMIN, MARKETING, PRODUCTS] defaults. AUTH_ROLE_PRODUCTS was added to this policy by #59, deliberately revising #56's earlier MARKETING-only alignment.
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:58 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:416-421. Admin menu entry at application/config/admin_menu.php:607-613 (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. The base table has no Phinx migration — it exists purely as initial schema — but the is_points_awarded column was added later via an ALTER TABLE migration (database/migrations/20260721120000_add_is_points_awarded_to_shop_product_reviews.php, #17). 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 |
is_points_awarded | TINYINT(1) NOT NULL DEFAULT 0 | 1 once loyalty points have been awarded for this review; the flag flip IS the guard — claimReviewPointsAward() sets it via one conditional UPDATE ... WHERE id = ? AND is_points_awarded != 1, so only the caller whose UPDATE actually changed the row is cleared to award (added by #17) |
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:130-143:
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:2554-2590). Reader: allowSendEmailForReview() helper at ecommercen/helpers/shopmodule_helper.php:180-186:
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:312-320), which callsisProductReviewedByCustomer($productId, $customerId)(:412-420). 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:418sets$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:38-73) 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) // :197-264
|
+--> checkAndSendEmail($reviewId, $status) // :266-276
| +--> getCustomerEmail($reviewId)
| +--> getProductMailStatus($reviewId)
| +--> if is_email_sent == 0:
| +--> adv_mailer::sendUserReviewStatusUpdateEmail(...)
| +--> setReviewEmailStatus($reviewId, 1)
|
+--> product_reviews_model->setReviewStatus($reviewId, $status) // :201
+--> $customerData = getCustomerEmail($reviewId) // :202
|
+--> if $customerData is null: set_userdata(set_status_error); loyalty block skipped
+--> else, if registry POINT_SYSTEM/IS_ENABLED AND $status == 1:
| +--> load library('loyalty')
| +--> if registry ECOMMERCEN_PLUS/CUSTOMER_REVIEW_REWARD_ENABLE:
| +--> trans_start() // :218
| +--> try:
| | +--> if claimReviewPointsAward($reviewId): // :221 — atomic claim, one conditional UPDATE
| | | +--> loyalty->savePointsToCustomer($customerData->id, $rewardValue)
| | +--> trans_complete()
| +--> catch (Throwable): trans_rollback(); rethrow // :226-252, rollback at :247
| +--> if trans_status() === false: set_userdata(set_status_error) // :256-258
|
+--> redirect to product_reviews_adminExact loyalty branch (ecommercen/eshop/controllers/Adv_product_reviews_admin.php:209-260), guarded per #17 by an atomic claim rather than a read-then-write check:
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');
// The claim IS the guard: one atomic conditional UPDATE, no
// preceding read, so two overlapping approvals of this review
// cannot both decide it is unawarded. Claim and award share one
// transaction, so a failed award rolls the claim back too.
$this->db->trans_start();
try {
if ($this->product_reviews_model->claimReviewPointsAward((int) $reviewId)) {
$this->loyalty->savePointsToCustomer($customerData->id, $rewardValue);
}
$this->db->trans_complete();
} catch (Throwable $awardFailure) {
// Belt-and-suspenders against an unexpected non-DB exception.
// A DB-level failure (deadlock included) returns false rather
// than throwing -- advmysqli disables PHP 8.1's mysqli
// exception mode on every connect -- so it never reaches here;
// trans_complete()'s auto-rollback plus the trans_status()
// check below already cover that case. If a non-DB exception
// did land here, trans_complete() would commit rather than
// roll back, so roll back explicitly and re-throw rather than
// fall through to the success redirect below.
$this->db->trans_rollback();
throw $awardFailure;
}
// A rolled-back transaction granted nothing; without this the
// admin would get the plain success redirect below regardless.
if ($this->db->trans_status() === false) {
$this->session->set_userdata('eshop_error', t('eshop.admin.product_reviews.set_status_error'));
}
}
}The claim (claimReviewPointsAward(), ecommercen/eshop/models/Adv_product_reviews_model.php:302-310) is a single UPDATE shop_product_reviews SET is_points_awarded = 1 WHERE id = ? AND is_points_awarded != 1, returning true only when it actually changed a row — i.e. only when this call won the claim. Points are awarded if and only if the claim returns true.
Driver note. AdvEshop4 runs a custom advmysqli driver (application/config/database.php:12), not stock PHP mysqli. Its db_connect() sets mysqli_driver::$report_mode = MYSQLI_REPORT_OFF on every connect under PHP 8.1+, which disables PHP 8.1's mysqli exception-on-error default process-wide. Any reasoning about mysqli error modes elsewhere in this doc — including the catch (Throwable) blocks below — must account for this: a DB-level failure (a query error, deadlock, or lock-wait timeout) returns false, it does not throw.
Bulk Status Change
text
Adv_product_reviews_admin::bulkSetStatus() // :81-194
|
+--> POST reviewIds[] and status
+--> product_reviews_model->setReviewsStatusBatch($ids, $status) // :87
|
+--> 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 = getCustomerIds($reviewIds) // :101
| +--> if any resolve (INNER JOIN drops orphaned reviews):
| +--> key by review id (dedupes), ksort ascending // :115-119
| +--> trans_start() // :121
| +--> try:
| | +--> foreach $reviewId => $customerId (ascending id order):
| | | +--> if claimReviewPointsAward($reviewId): // :138 — atomic claim, one review at a time
| | | | +--> loyalty->savePointsToCustomer($customerId, $rewardValue)
| | | +--> else: continue (already claimed by another call)
| | +--> trans_complete()
| +--> catch (Throwable): trans_rollback(); rethrow // :146-160, rollback at :155
| +--> if trans_status() === false: set_flashdata(set_status_error) // :164-166
|
+--> getProductMailStatuses($ids) // :171
+--> 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:81-194) mirrors setStatus()'s loyalty branch, resolving one customer id per review via the model method getCustomerIds(array $reviewIds) (ecommercen/eshop/models/Adv_product_reviews_model.php:491-506) and calling loyalty->savePointsToCustomer() once per approved review. Bulk-approving 50 reviews awards 50 × CUSTOMER_REVIEW_REWARD, matching single approval.
Fixed in #17: the award is now idempotent per review, and safe under concurrency. Rather than reading is_points_awarded and filtering the batch, bulkSetStatus() claims one review at a time inside a transaction (ecommercen/eshop/controllers/Adv_product_reviews_admin.php:121-166): the resolved customer ids are keyed by review id and sorted ascending (:115-119, so concurrent batches take their shop_product_reviews row locks in a consistent order), then each iteration calls claimReviewPointsAward((int) $reviewId) (:138) — the same atomic conditional UPDATE used by setStatus() — and awards only when the claim returns true. A batched WHERE id IN (...) claim was deliberately not used: it can report only how many rows it claimed, never which, so it cannot say whom to award. The ascending-id ordering only orders the review-row locks; each iteration also locks a shop_customer row via Adv_loyalty::savePointsToCustomer(), and those locks are not similarly ordered, so two individually-sorted batches whose reviews map to customers in opposite relative order can still deadlock on the customer rows — a deadlock is a DB-level failure, so it returns false rather than throwing (see the driver note above), and the whole batch is rolled back via the trans_status() check below. On a rolled-back award, bulkSetStatus() checks $this->db->trans_status() after trans_complete() (:164-166) and surfaces eshop.admin.product_reviews.set_status_error via set_flashdata instead of the plain success redirect; this is also how a deadlock or lock-wait timeout on the customer-row lock surfaces. Separately, the catch (Throwable) block (:146-160), which rolls back explicitly via trans_rollback() (:155) and re-throws, is belt-and-suspenders against an unexpected non-DB exception — not the path a DB deadlock takes — because trans_complete()'s auto-rollback only fires when CodeIgniter recorded a returned failure, never when something throws. See the Loyalty Integration Gap section, Known Issue #7 (resolved).
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)(:249-254): looks upproduct_idviagetProductIdForReview($reviewId)(:353-362) before the UPDATE, then callsrecomputeRatingAggregates()after. Covers admin single-review moderation.setReviewsStatusBatch($reviewIds, $status)(:256-261): looks up all affectedproduct_ids viagetProductIdsForReviews($reviewIds)(:369-384) before the batch UPDATE, then recomputes each distinct product. Covers bulk moderation.addReview($data)(:322-330): recomputes for$data['product_id']after insert. Covers customer review submission viaapplication/controllers/Webrun.php.updateReview($reviewId, $data)(:340-348): 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) | :353-362 | Fetch product_id for a single review |
getProductIdsForReviews(array $reviewIds) | :369-384 | Fetch distinct product_ids for a batch |
recomputeRatingAggregates(array $productIds) | :390-400 | Calls di()->get(RatingAggregator::class)->recomputeForProduct() for each product |
Email Templates
Mailer method: adv_mailer::sendUserReviewStatusUpdateEmail($customerData, $status) at application/models/Adv_mailer.php:393-410.
Template selection uses a ternary at :401:
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; the admin preview resolves to the same main/mail/ directory (the deprecated client_views config is not consulted on that path) — see AD-53 Email Template Viewer. 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), carrying a visibilityScope (#637). The class now depends on ProductVisibilityScope, injected via constructor (:12-14: __construct(private readonly ProductVisibilityScope $productVisibilityScope)), and the relation is declared with visibilityScope: $this->productVisibilityScope->relationScope() (:32-37) |
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:698-703(read) and:706-711(write) - REST policy at
application/config/rest_policies.php:873: backend auth with[ADMIN, MARKETING, PRODUCTS]— widened alongside the siblingReview::classpolicy by #59 (see Configuration). This policy carries nomethodsoverride, so the grant reachesstore/update/destroyon this dedup log too — an accepted consequence of #59's AC 4, not a defect (see Known Issue #3)
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:206; the cron schedule is currently commented out atapplication/config/jobs.php:63-65— the job is opt-in per deployment. The schedule, when enabled, is30 17 * * * - Recipient queries live at
ecommercen/eshop/models/Adv_order_model.php:2592-2667($daysSpan = 4at:2594).getMailsForGoogleReview()(:2611),getMailsForFacebookReview()(:2639) andgetMailsForSkroutzReview()(:2666) return[]when a day has no matching orders, so thecount($records)/foreachinsendMailForReviewToCustomers()(:2576-2577) no longer fatal on afalseresult (see SY-11 Review Reminders) - Notification emails sent via
adv_mailer::sendCustomerPromptReview()atapplication/models/Adv_mailer.php:412
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}savePointsToCustomer() itself is still an unconditional raw update — it has no idempotency guard, and deliberately so: it is shared by other loyalty-award flows (see SY-10 Loyalty Points Jobs) that must not be constrained by review-specific state. Both defects that previously existed around its use from review moderation are now resolved at the caller:
- Bulk vs single divergence — resolved (#16).
setStatus()applies the loyalty branch (Adv_product_reviews_admin.php:158-168) andbulkSetStatus()(:88-118) applies the same branch, resolving one customer id per review viagetCustomerIds()(Adv_product_reviews_model.php:491-506) 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 — resolved (#17), including under concurrency. A new
is_points_awarded TINYINT(1) NOT NULL DEFAULT 0column onshop_product_reviews(migrationdatabase/migrations/20260721120000_add_is_points_awarded_to_shop_product_reviews.php) mirrors the existingis_email_sentguard, but for points instead of email. The guard is no longer "read the flag, award, then write the flag" — it is an atomic claim:Adv_product_reviews_model::claimReviewPointsAward(int $reviewId): bool(ecommercen/eshop/models/Adv_product_reviews_model.php:302-310) issues a single conditionalUPDATE shop_product_reviews SET is_points_awarded = 1 WHERE id = ? AND is_points_awarded != 1, returningtrueonly when that statement changed a row. BothsetStatus()(ecommercen/eshop/controllers/Adv_product_reviews_admin.php:193-250) andbulkSetStatus()(:80-190) award points if and only if the claim returnstrue, with claim and award sharing one transaction (trans_start()/trans_complete()) so a failed award rolls the claim back and the review stays claimable. This closes a real gap in the previous check-then-act design: readingis_points_awardedand then writing it back left a window where two overlapping approvals of the same review (an admin double-click, or two admins acting at once) could both read0and both award — safe only for sequential re-approvals, not concurrent ones. The at-most-once guarantee is now enforced by the database itself. Both call sites also check$this->db->trans_status()aftertrans_complete()and, on a rolled-back award, surface the existingeshop.admin.product_reviews.set_status_errormessage instead of a plain success redirect — this is also the path a DB-level failure such as a deadlock or lock-wait timeout takes, since AdvEshop4's customadvmysqlidriver disables PHP 8.1's mysqli exception mode on every connect (see the driver note above), so a DB error returnsfalserather than throwing. Separately, an unexpected non-DB exception is caught via an explicittry/catch (Throwable), rolled back viatrans_rollback(), and re-thrown, sincetrans_complete()'s auto-rollback only fires on a CodeIgniter-recorded returned failure, never on a throw. The guard lives in the two admin-controller call sites, not inside the sharedsavePointsToCustomer()(see above) — a caller that awards points outsideAdv_product_reviews_adminis not automatically protected. One-time caveat: the migration backfillsis_points_awarded = 0for every pre-existing row, including already-approved reviews — there is no data backfill marking historically-awarded reviews as awarded — so a review that was already approved (and already awarded points) before this fix deployed may award one extra time on its first re-approval post-deploy. This is a forward-only fix; it does not retroactively correct past double-awards.
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 under the eCommercenPlus() view at ecommercen/settings/controllers/Adv_settings.php:2066-2132, with emailsForRewardForReview POST writes at :2101-2109, view values at :2125-2126, and validation at :2144-2145. 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 (277 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:393-410 | Sends accept / reject email |
adv_mailer::sendCustomerPromptReview | application/models/Adv_mailer.php:412 | 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:416-421 | Admin controller routes |
| REST routes (read) | application/config/rest_routes.php:573-578 | Modern REST GET routes |
| REST routes (write) | application/config/rest_routes.php:1627-1632 | Modern REST POST/DELETE routes |
| REST policy | application/config/rest_policies.php:365-376 | Review RBAC (guest reads, backend writes) |
| Admin menu | application/config/admin_menu.php:607-613 | 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) | 211-241 | Storefront read; only active=1 |
setReviewStatus($id, $status) | 249-254 | UPDATE a single review's status; recomputes rating aggregate after |
setReviewsStatusBatch($ids, $status) | 256-261 | Batch UPDATE WHERE IN; recomputes rating aggregate for each distinct product after |
setReviewEmailStatus($id, 1) | 263-266 | Mark single review as emailed |
setReviewEmailStatusBatch($ids, 1) | 268-271 | Batch mark as emailed |
getProductMailStatuses($ids) | 273-278 | Fetch is_email_sent for many ids |
claimReviewPointsAward(int $reviewId): bool | 302-310 | Atomically claim the loyalty award for one review; one conditional UPDATE ... WHERE id = ? AND is_points_awarded != 1, returns true only if this call won the claim (added by #17, replacing four now-deleted read/write methods) |
safeAddReview($data) | 312-320 | Insert-if-not-dup via isProductReviewedByCustomer |
addReview($data) | 322-330 | Insert a review; recomputes rating aggregate after |
updateReview($reviewId, $data) | 340-348 | Update a review; recomputes rating aggregate for old and new product_id |
isProductReviewedByCustomer($p, $c) | 412-420 | Dedup check |
getProductIdForReview($reviewId) | 353-362 | Fetch product_id for a single review (used by aggregation) |
getProductIdsForReviews(array $reviewIds) | 369-384 | Fetch distinct product_ids for a batch (used by aggregation) |
recomputeRatingAggregates(array $productIds) | 390-400 | Delegates to RatingAggregator::recomputeForProduct() via di() |
getCustomerReviews($customerId) | 422-445 | Customer "My Reviews"; only active=1 |
getCustomerEmail($reviewId) | 452-464 | Resolve recipient for single review email |
getCustomerEmails($reviewIds) | 465-479 | Resolve recipients for bulk email |
getCustomerIds(array $reviewIds) | 491-506 | Resolve one customer id per review (used by bulk loyalty award) |
showReviewsForGoogle() | 508-527 | Requires at least 50 approved reviews before exporting |
getReviewsForGoogle() | 529-554 | 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:130-143 | Prompt campaign keys (reviewForFacebook/Skroutz/Google) |
| Config | application/config/jobs.php:63-65 | 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, AUTH_ROLE_PRODUCTS (checked at ecommercen/eshop/controllers/Adv_product_reviews_admin.php:20-27).
Required roles (REST writes): AUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING, AUTH_ROLE_PRODUCTS (declared at application/config/rest_policies.php:365-376). Aligned with the legacy controller's allow-list — deliberately revised by #59 to add AUTH_ROLE_PRODUCTS on both layers, superseding #56's earlier MARKETING-only alignment.
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/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:393-410). - Bulk and single approval award loyalty identically (fixed #16) and idempotently per review, including under concurrency (fixed #17). Both
setStatus()andbulkSetStatus()apply the loyalty branch; bulk resolves one customer id per review viagetCustomerIds()and awards each approved review separately. Both paths now guard the award with an atomic claim on the per-reviewis_points_awardedflag —claimReviewPointsAward(), a single conditionalUPDATE ... WHERE id = ? AND is_points_awarded != 1— awarding only when the claim itself changed a row, so a repeated approve/pending/approve cycle or two overlapping approvals of the same review award points at most once — see Known Issue #7 (resolved). - 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/PRODUCTS— the same roles as the legacy admin;AUTH_ROLE_ADVISABLEis deliberately omitted from RESTrolesarrays because it's a superuser that unconditionally bypasses role checks in middleware (application/config/rest_policies.php:33-34), so the two layers are equivalent for ADVISABLE, not divergent. - 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:180-186). 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 as
ADMIN/MARKETING; revised by #59 to addPRODUCTS). Both layers now requireADMIN/MARKETING/PRODUCTS: legacy atecommercen/eshop/controllers/Adv_product_reviews_admin.php:20-27(which additionally requiresADVISABLE, deliberately omitted from RESTrolesarrays since it auto-bypasses role checks in middleware —application/config/rest_policies.php:33-34), REST atapplication/config/rest_policies.php:365-376. #59 is a deliberate, product-owner-approved access widening — not a security fix — on the grounds that PRODUCTS already owns the catalog, reviews hang off products, and the admin menu has always filed the entry under the PRODUCTS group. 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-190) applies the same loyalty branch assetStatus()(:193-250), resolving one customer id per review via thegetCustomerIds()model method (ecommercen/eshop/models/Adv_product_reviews_model.php:491-506) and awarding each approved review individually. The award's idempotency was a separate concern, tracked and resolved by #17 — see Known Issue #7 below. - Loyalty award is not idempotent (resolved by #17, including under concurrency).
Adv_loyalty::savePointsToCustomerstill runs a rawtotal_points = total_points + N(ecommercen/libraries/Adv_loyalty.php:236-244) — that method itself is intentionally left unguarded, since it is shared by non-review loyalty flows. The non-idempotency was fixed at the call site instead, via an atomic claim rather than a read-then-write check: a newis_points_awardedcolumn onshop_product_reviews(migrationdatabase/migrations/20260721120000_add_is_points_awarded_to_shop_product_reviews.php) mirrorsis_email_sent, andAdv_product_reviews_model::claimReviewPointsAward(int $reviewId): bool(ecommercen/eshop/models/Adv_product_reviews_model.php:302-310) claims it with a single conditionalUPDATE ... WHERE id = ? AND is_points_awarded != 1, returningtrueonly when that statement changed a row. BothsetStatus()(ecommercen/eshop/controllers/Adv_product_reviews_admin.php:193-250) andbulkSetStatus()(:80-190) now award if and only if the claim returnstrue, with claim and award sharing one transaction so a failed award rolls the claim back and the review stays claimable; both also surfaceeshop.admin.product_reviews.set_status_error(viatrans_status()aftertrans_complete()) instead of a plain success redirect when the award transaction rolls back — including on a DB-level failure such as a deadlock or lock-wait timeout, since AdvEshop4's customadvmysqlidriver disables PHP 8.1's mysqli exception mode on every connect (see the driver note under Code Flow → Single Review Status Change), so a DB error returnsfalserather than throwing. Both also explicitlytrans_rollback()and re-throw on an unexpected non-DB exception, sincetrans_complete()'s auto-rollback only fires on a CodeIgniter-recorded returned failure, never on a throw. Because the claim is atomic rather than check-then-act, apending → approved → pending → approvedcycle and two overlapping approvals of the same review (an admin double-click, or two admins at once) both award points at most once per review — the previous read-then-write design was only safe for the sequential case.bulkSetStatus()claims one review at a time in ascending review-id order rather than via a single batched claim, because a batchedWHERE id IN (...)claim can only report how many rows it took, never which, so it cannot say whom to award; the ordering keeps concurrent batches'shop_product_reviewsrow locks consistent but is not blanket deadlock immunity, since each iteration'sshop_customerrow lock (viasavePointsToCustomer()) is not similarly ordered. Caveat: pre-existing approved reviews getis_points_awarded = 0after the migration (no backfill), so a review that was already approved and awarded before this fix deployed may award one extra time on its first re-approval post-deploy. - 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:63-65ships with the schedule disabled; the 4-day delivery window is hard-coded into the SQL queries inAdv_order_model.php:2592-2667. - Email template selector treats any non-1 status as "rejected". Including
status=0(pending).application/models/Adv_mailer.php:393-410. - Legacy admin allow-list now includes
AUTH_ROLE_PRODUCTS(resolved by #59; previously missing despite the menu entry living under the PRODUCTS group).ecommercen/eshop/controllers/Adv_product_reviews_admin.php:20-27vsapplication/config/admin_menu.php:607-613. A deliberate, product-owner-approved widening that supersedes #56's MARKETING-only alignment — not a menu-visibility fix, since the menu's child-entryrolesarray was updated in lockstep, not left as pre-existing dead-code access. - Settings UI POST fields are misleadingly named. The field names
emailsForRewardForReview/emailsForRewardForReviewValueatecommercen/settings/controllers/Adv_settings.php:2066-2132(POST writes:2101-2109, view values:2125-2126, validation:2144-2145) actually configure review-approval rewards, not any kind of "email-for-reward" feature. AUTH_ROLE_PRODUCTSadmins can now award customer loyalty points via review moderation (accepted, unmitigated consequence of #59). BothbulkSetStatus()(ecommercen/eshop/controllers/Adv_product_reviews_admin.php:90-143) andsetStatus()(:213-226) callAdv_loyalty::savePointsToCustomer()on approval, gated only by the registry switchesPOINT_SYSTEM/IS_ENABLEDandECOMMERCEN_PLUS/CUSTOMER_REVIEW_REWARD_ENABLE(seeded'0', so off unless a client enabled it) — neither check is role-scoped. Since #59 widened moderation access to includeAUTH_ROLE_PRODUCTSon both layers, on any install where the reward flag is on, a PRODUCTS-only admin can now issueECOMMERCEN_PLUS/CUSTOMER_REVIEW_REWARDloyalty points to a customer simply by approving their review. The product owner accepted this as part of #59's deliberate access widening; no additional guard was added.- SQL injection in the admin review search.
ecommercen/eshop/models/Adv_product_reviews_model.php:130-132interpolates$conditions['date_start']raw into a where clause thatfixWhereCondition()(:145) then applies with escaping disabled:$this->db->where($value, null, false). The three sibling fieldssearchReview/searchCommenter/searchMailall useescape_like_str()(:116,:121,:126);date_startuses nothing.date_startis a live form field (application/views/admin/reviews/list.php:58) whose unfiltered POST array is stored in sessionprdSearchatecommercen/eshop/controllers/Adv_product_reviews_admin.php:44. Reachable by every moderation role, including theAUTH_ROLE_PRODUCTSgrant #59 added. - Unescaped reflection of all four search fields into HTML attributes.
application/views/admin/reviews/list.php:25, 31, 38, 60emit$records['searchReview'|'searchCommenter'|'searchMail'|'date_start']raw inside double-quotedvalue="…"— quote break-out XSS, persisted across requests via theprdSearchsession key. Same shape as #580 (email_subjects.php). - Unguarded null dereference on an unknown review id.
getProductMailStatus()returnsnullwhen the id matches no row (Adv_product_reviews_model.php:43), butcheckAndSendEmail()dereferences$reviewMailStatus->is_email_sentwith no null check (Adv_product_reviews_admin.php:272).setStatus()callscheckAndSendEmail()at:199— before its ownis_null($customerData)guard at:204— soGET /eshop/product_reviews_admin/setStatus/{badId}/1warns on null and then callssendUserReviewStatusUpdateEmail(null, $status). - The OWNING review's product visibility is scoped for
with=product, but the review row itself is not.src/Domains/Product/Review/Repository/RepositoryConfigurator.phpgained avisibilityScopeon itsproductrelation (#637,:12-14+:32-37), so a guestGET /rest/product/review?with=productfor a review of an inactive or soft-deleted product now serialisesproduct: nullinstead of leaking the product row. But the review row itself is still returned unscoped —index/show/itemareguestin policy (see API Reference) with no row-level filter on the review, so the review for a hidden product is still listed, just with a nullproductrelation. Row scoping for owning entities (as opposed to related entities) is deliberately out of scope for this work and tracked under #624, #625, #626, and #627. Corroborated byapplication/config/rest_api_versions.php:68, which names this endpoint explicitly in the REST 1.40 changelog entry.
Tests
Both layers now have automated coverage.
Modern domain/REST layer (suite Database):
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
Legacy moderation controller and loyalty side-effects:
tests/Legacy/Eshop/AdvProductReviewsAdminLoyaltyIdempotencyTest.php(suiteLegacy, added by #17) — controller-level, stubbed collaborators, no DB. Covers the transactional award mechanics for bothsetStatus()andbulkSetStatus(): exactly-once award across an approve→pending→approve cycle, claim+award inside one transaction, already-flagged reviews never re-awarded, concurrent approvals of the same review awarding once,trans_status() === falsesurfacing an error and leaving the review re-claimable, and aThrowablebetween claim and award triggering an explicit rollback + rethrow (whole-batch rollback in the bulk case).tests/Legacy/Eshop/AdvProductReviewsAdminStatusTransitionsTest.php(suiteLegacy, added by #18; 47 tests / 254 assertions) — covers the registry-gating matrix on both paths (POINT_SYSTEM/IS_ENABLED,ECOMMERCEN_PLUS/CUSTOMER_REVIEW_REWARD_ENABLE, and$status == 1), status values passed straight through to the model, bulk with no selected reviews, an unresolvable customer on the single path, the status-email side-branch (sent once, not resent, and bulk mailing only not-yet-notified reviews), and — as the regression guard for the single/bulk divergence fixed by #16 — an explicit parity assertion that single and bulk reach the same award decision on identical inputs. It also pins the deliberate asymmetry that the single path reports errors viasession->set_userdatawhile bulk usesset_flashdata.tests/Integration/Legacy/Eshop/AdvLoyaltySavePointsToCustomerTest.php(suiteDatabase, added by #18; 9 tests) — coversAdv_loyalty::savePointsToCustomer()against a real DB: awards to the target customer only, successive awards accumulating, an empty points value being a silent no-op (no query issued at all), a negative value subtracting, and a characterization test pinning that the method is NOT idempotent — two identical calls double-add. This is Known Issue #7: exactly-once is enforced by the caller'sclaimReviewPointsAward(), never by the library itself. The test pins current behaviour rather than endorsing it — a future fix should update the test, not "repair" it.tests/Integration/Legacy/Eshop/AdvProductReviewsModelPointsClaimTest.php(suiteDatabase, added by #18; 12 tests) — coversAdv_product_reviews_model::claimReviewPointsAward()(first claim wins and sets the flag, an immediate second claim loses and leaves the row unchanged, an already-flagged or non-existent review loses, a claim flags only its own review),getCustomerIds()(one customer pair per resolvable review, silently dropping a review whose customer no longer exists via the INNER JOIN, empty results for empty/non-matching input), andsetReviewStatus()/setReviewsStatusBatch()writing theactivecolumn without disturbing the points-claim flag.
tests/Legacy/** runs in the Legacy suite (no DB); tests/Integration/** runs in the Database suite (not Integration) and needs a migrated database.
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 both single and bulk 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