Skip to content

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 (new ProductCode/ActiveGift/CustomMetaTag use imports and a new ActiveGift::class policy block), rest_routes.php (new ActiveGift GET route block), routes.php (blog-comment-admin routing fix), and Adv_product_reviews_model.php (getProductReviews() docblock expansion) and Adv_order_model.php (gift-stock restore method insertions); also corrected application/models/Adv_mailer.php:336-353 → :393-410 (Business Rules item 4, previously wrong before this diff) and two internally-inconsistent getCustomerIds()/safeAddReview() citations to match the doc's own Legacy Model Methods table; a follow-up pass the same day corrected the Customer Submission section's isProductReviewedByCustomer() citation from :364-372 (which is actually getProductIdsForReviews()) to :412-420 to 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.php gained a visibilityScope constructor dependency on its product relation) and other unrelated line shifts across rest_policies.php, rest_routes.php, app.php, Adv_products.php, Adv_order_model.php, and Adv_settings.php; added Known Issue #17 (owning review row still unscoped for with=product, tracked under #624/#625/#626/#627); previously: 2026-08-12 — #59: granted AUTH_ROLE_PRODUCTS review-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 from default/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.

MethodPathActionAuthRoles
GET/rest/product/reviewindexguest—
GET/rest/product/review/{id}showguest—
GET/rest/product/review/itemitemguest—
POST/rest/product/reviewstorecustomer— (customer-scoped storefront submission)
POST/rest/product/review/{id}updatebackendAUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING, AUTH_ROLE_PRODUCTS
DELETE/rest/product/review/{id}destroybackendAUTH_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 ​

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

Routes declared at application/config/routes.php:416-421. Admin menu entry at application/config/admin_menu.php:607-613 (PRODUCTS group, customer_interaction.products).

Customer-facing Submission ​

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

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


Data Model ​

shop_product_reviews ​

Defined in database/initial/initial.sql:1801-1822. This is the only table that stores actual product reviews. 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.

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

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

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

shop_customer_reviews — NOT product reviews ​

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

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

Indexes: PRIMARY (id), mail, action_id_mail.

Campaign keys are declared at application/config/app.php: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:

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

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


Code Flow ​

Listing Reviews ​

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

Single Review Status Change ​

text
Adv_product_reviews_admin::setStatus($reviewId, $status)   // :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_admin

Exact 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_admin

Fixed 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:

  1. setReviewStatus($reviewId, $status) (:249-254): looks up product_id via getProductIdForReview($reviewId) (:353-362) before the UPDATE, then calls recomputeRatingAggregates() after. Covers admin single-review moderation.

  2. setReviewsStatusBatch($reviewIds, $status) (:256-261): looks up all affected product_ids via getProductIdsForReviews($reviewIds) (:369-384) before the batch UPDATE, then recomputes each distinct product. Covers bulk moderation.

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

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

Private helpers added:

MethodLinesPurpose
getProductIdForReview($reviewId):353-362Fetch product_id for a single review
getProductIdsForReviews(array $reviewIds):369-384Fetch distinct product_ids for a batch
recomputeRatingAggregates(array $productIds):390-400Calls 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 ​

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

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

Advisable\Domains\Customer\CustomerReview ​

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

  • src/Domains/Customer/CustomerReview/Repository/Repository.php — $table = 'shop_customer_reviews'
  • Uses NullRelationConfigurator because rows are identified by mail, not customer_id (noted in domain-coverage.md:83)
  • REST controller src/Rest/Customer/Controllers/CustomerReview.php exposes filters id, actionId, mail (partial)
  • REST routes at application/config/rest_routes.php: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 sibling Review::class policy by #59 (see Configuration). This policy carries no methods override, so the grant reaches store/update/destroy on 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 productId and a 1-5 starPoints rating, else ValidationException
  • One review per product per customer (repository->existsForProductAndCustomer()) — the same dedup as the legacy safeAddReview()

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

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

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

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

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


Email Prompts (post-purchase) — separate flow ​

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

Key facts relevant to this flow:

  • Job registered at application/config/jobs.php:206; the cron schedule is currently commented out at application/config/jobs.php:63-65 — the job is opt-in per deployment. The schedule, when enabled, is 30 17 * * *
  • Recipient queries live at ecommercen/eshop/models/Adv_order_model.php:2592-2667 ($daysSpan = 4 at :2594). getMailsForGoogleReview() (:2611), getMailsForFacebookReview() (:2639) and getMailsForSkroutzReview() (:2666) return [] when a day has no matching orders, so the count($records) / foreach in sendMailForReviewToCustomers() (:2576-2577) no longer fatal on a false result (see SY-11 Review Reminders)
  • Notification emails sent via adv_mailer::sendCustomerPromptReview() at application/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() and bulkSetStatus() execute the DB update and email dispatch inline on the HTTP request.
  • The only review-related cron is the (commented-out) post-purchase prompt email job described above.

Loyalty Integration Gap ​

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

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

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:

  1. Bulk vs single divergence — resolved (#16). setStatus() applies the loyalty branch (Adv_product_reviews_admin.php:158-168) and bulkSetStatus() (:88-118) applies the same branch, resolving one customer id per review via getCustomerIds() (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 award 50 × CUSTOMER_REVIEW_REWARD.
  2. Not idempotent — resolved (#17), including under concurrency. A new is_points_awarded TINYINT(1) NOT NULL DEFAULT 0 column on shop_product_reviews (migration database/migrations/20260721120000_add_is_points_awarded_to_shop_product_reviews.php) mirrors the existing is_email_sent guard, 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 conditional UPDATE shop_product_reviews SET is_points_awarded = 1 WHERE id = ? AND is_points_awarded != 1, returning true only when that statement changed a row. Both setStatus() (ecommercen/eshop/controllers/Adv_product_reviews_admin.php:193-250) and bulkSetStatus() (:80-190) award points if and only if the claim returns true, 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: reading is_points_awarded and 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 read 0 and 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() after trans_complete() and, on a rolled-back award, surface the existing eshop.admin.product_reviews.set_status_error message 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 custom advmysqli driver disables PHP 8.1's mysqli exception mode on every connect (see the driver note above), so a DB error returns false rather than throwing. Separately, an unexpected non-DB exception is caught via an explicit try/catch (Throwable), rolled back via trans_rollback(), and re-thrown, since trans_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 shared savePointsToCustomer() (see above) — a caller that awards points outside Adv_product_reviews_admin is not automatically protected. One-time caveat: the migration backfills is_points_awarded = 0 for 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:

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

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

Settings UI lives 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 ​

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

Legacy Model Methods ​

ecommercen/eshop/models/Adv_product_reviews_model.php:

MethodLinesPurpose
getProductReviewsAdmin()29-34Returns ['results', 'numRows']
getProductMailStatus($id)36-44Fetch is_email_sent for one review
getProductReviewsAdminResults()46-79Joined query; filters by admin languageAbbr
getProductReviewsAdminNumRows()81-94COUNT query for pagination
fixAdminSearch()97-135Maps UI filters to active values
getProductReviews($productId)211-241Storefront read; only active=1
setReviewStatus($id, $status)249-254UPDATE a single review's status; recomputes rating aggregate after
setReviewsStatusBatch($ids, $status)256-261Batch UPDATE WHERE IN; recomputes rating aggregate for each distinct product after
setReviewEmailStatus($id, 1)263-266Mark single review as emailed
setReviewEmailStatusBatch($ids, 1)268-271Batch mark as emailed
getProductMailStatuses($ids)273-278Fetch is_email_sent for many ids
claimReviewPointsAward(int $reviewId): bool302-310Atomically 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-320Insert-if-not-dup via isProductReviewedByCustomer
addReview($data)322-330Insert a review; recomputes rating aggregate after
updateReview($reviewId, $data)340-348Update a review; recomputes rating aggregate for old and new product_id
isProductReviewedByCustomer($p, $c)412-420Dedup check
getProductIdForReview($reviewId)353-362Fetch product_id for a single review (used by aggregation)
getProductIdsForReviews(array $reviewIds)369-384Fetch distinct product_ids for a batch (used by aggregation)
recomputeRatingAggregates(array $productIds)390-400Delegates to RatingAggregator::recomputeForProduct() via di()
getCustomerReviews($customerId)422-445Customer "My Reviews"; only active=1
getCustomerEmail($reviewId)452-464Resolve recipient for single review email
getCustomerEmails($reviewIds)465-479Resolve recipients for bulk email
getCustomerIds(array $reviewIds)491-506Resolve one customer id per review (used by bulk loyalty award)
showReviewsForGoogle()508-527Requires at least 50 approved reviews before exporting
getReviewsForGoogle()529-554Google Merchant feed export (only active=1)

Configuration ​

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

Required roles (legacy admin): AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING, 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 for Advisable\Domains\Product\Review\... in custom/Domains/container.php.
  • Custom email templates: Override product_review_accept.php and product_review_reject.php in the client view set (application/views/main/mail/).
  • Override prompt email job: Extend application/modules/job/libraries/SendMailToCustomersForReview.php (empty subclass already in place).

Business Rules ​

  1. Pending by default. Customer submissions hard-code active=false/0. There is no registry-driven auto-approve. (application/controllers/Webrun.php:32-73)
  2. At most one review per product per customer. Enforced by isProductReviewedByCustomer() and the insert-if-not-dup safeAddReview().
  3. Tri-state status. active is 0=pending, 1=approved, 2=rejected. Storefront and feed queries filter to active=1 only.
  4. Email once per review. is_email_sent guards duplicate notifications. Resetting an approved review back to pending will send the "rejected" email because the template selector treats anything non-1 as rejected (application/models/Adv_mailer.php:393-410).
  5. Bulk and single approval award loyalty identically (fixed #16) and idempotently per review, including under concurrency (fixed #17). Both setStatus() and bulkSetStatus() apply the loyalty branch; bulk resolves one customer id per review via getCustomerIds() and awards each approved review separately. Both paths now guard the award with an atomic claim on the per-review is_points_awarded flag — claimReviewPointsAward(), a single conditional UPDATE ... 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).
  6. Language-scoped admin listing. getProductReviewsAdminResults() joins on languageAbbr, so reviews in other languages are invisible from the current admin language context.
  7. REST reads are public; write RBAC matches the legacy admin. index/show/item are guest in policy; store is customer (storefront submission); update/destroy require ADMIN/MARKETING/PRODUCTS — the same roles as the legacy admin; AUTH_ROLE_ADVISABLE is deliberately omitted from REST roles arrays 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.
  8. REST moderation bypasses the notification side-effects. Customer submissions via REST store() do get pending-by-default (active=0) and one-per-product dedup, but approving/rejecting through REST update sends no moderation email and awards no loyalty points — clients must not use REST to approve reviews if they need those side-effects.

Known Issues & Security Gaps ​

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

  1. active is tri-state, not boolean. Any client assuming active is a 0/1 boolean will mishandle rejected reviews. Confirmed by the inline DDL comment in database/initial/initial.sql:1801-1822.
  2. No _mui companion table. Multi-language is stored per-row via the lang column. Admin listing is scoped by the admin's current language only (ecommercen/eshop/models/Adv_product_reviews_model.php:46-79).
  3. shop_customer_reviews is NOT product reviews. It is the dedup log for post-purchase prompt emails (database/initial/initial.sql:1209-1217, ecommercen/helpers/shopmodule_helper.php:180-186). Any code assuming otherwise is wrong.
  4. Modern domain layer is write-capable, but moderation is legacy-only. src/Domains/Product/Review/ has full CQRS (Service, WriteService, Validator, Repository, WriteRepository) and the REST controller src/Rest/Product/Controllers/Review.php uses HandlesWriteActions. Customer submission via REST store() now applies pending-by-default (active=0), server-forced identity/date fields, a 1-5 star check, and one-review-per-product dedup (velora#42). What the modern path still does NOT do: notification emails on status change, loyalty-point award. Those two side-effects still live exclusively in Adv_product_reviews_admin.
  5. REST RBAC alignment with legacy (resolved by #56 as ADMIN/MARKETING; revised by #59 to add PRODUCTS). Both layers now require ADMIN/MARKETING/PRODUCTS: legacy at ecommercen/eshop/controllers/Adv_product_reviews_admin.php:20-27 (which additionally requires ADVISABLE, deliberately omitted from REST roles arrays since it auto-bypasses role checks in middleware — application/config/rest_policies.php:33-34), REST at application/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 in tests/Unit/Rest/Middleware/PolicyResolverIntegrationTest.php so any future drift fails CI.
  6. Bulk vs single loyalty gap (resolved by #16). bulkSetStatus() (ecommercen/eshop/controllers/Adv_product_reviews_admin.php:80-190) applies the same loyalty branch as setStatus() (:193-250), resolving one customer id per review via the getCustomerIds() 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.
  7. Loyalty award is not idempotent (resolved by #17, including under concurrency). Adv_loyalty::savePointsToCustomer still runs a raw total_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 new is_points_awarded column on shop_product_reviews (migration database/migrations/20260721120000_add_is_points_awarded_to_shop_product_reviews.php) mirrors is_email_sent, and Adv_product_reviews_model::claimReviewPointsAward(int $reviewId): bool (ecommercen/eshop/models/Adv_product_reviews_model.php:302-310) claims it with a single conditional UPDATE ... WHERE id = ? AND is_points_awarded != 1, returning true only when that statement changed a row. Both setStatus() (ecommercen/eshop/controllers/Adv_product_reviews_admin.php:193-250) and bulkSetStatus() (:80-190) now award if and only if the claim returns true, with claim and award sharing one transaction so a failed award rolls the claim back and the review stays claimable; both also surface eshop.admin.product_reviews.set_status_error (via trans_status() after trans_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 custom advmysqli driver 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 returns false rather than throwing. Both also explicitly trans_rollback() and re-throw on an unexpected non-DB exception, since trans_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, a pending → approved → pending → approved cycle 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 batched WHERE 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_reviews row locks consistent but is not blanket deadlock immunity, since each iteration's shop_customer row lock (via savePointsToCustomer()) is not similarly ordered. Caveat: pre-existing approved reviews get is_points_awarded = 0 after 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.
  8. Customer submission has no captcha, no rate limiting, no purchase verification. Webrun::submit_product_review() (application/controllers/Webrun.php:32-73) only requires a logged-in customer_id and an un-reviewed product.
  9. Prompt email cron is commented out by default. application/config/jobs.php:63-65 ships with the schedule disabled; the 4-day delivery window is hard-coded into the SQL queries in Adv_order_model.php:2592-2667.
  10. Email template selector treats any non-1 status as "rejected". Including status=0 (pending). application/models/Adv_mailer.php:393-410.
  11. 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-27 vs application/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-entry roles array was updated in lockstep, not left as pre-existing dead-code access.
  12. Settings UI POST fields are misleadingly named. The field names emailsForRewardForReview / emailsForRewardForReviewValue at ecommercen/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.
  13. AUTH_ROLE_PRODUCTS admins can now award customer loyalty points via review moderation (accepted, unmitigated consequence of #59). Both bulkSetStatus() (ecommercen/eshop/controllers/Adv_product_reviews_admin.php:90-143) and setStatus() (:213-226) call Adv_loyalty::savePointsToCustomer() on approval, gated only by the registry switches POINT_SYSTEM/IS_ENABLED and ECOMMERCEN_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 include AUTH_ROLE_PRODUCTS on both layers, on any install where the reward flag is on, a PRODUCTS-only admin can now issue ECOMMERCEN_PLUS/CUSTOMER_REVIEW_REWARD loyalty 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.
  14. SQL injection in the admin review search. ecommercen/eshop/models/Adv_product_reviews_model.php:130-132 interpolates $conditions['date_start'] raw into a where clause that fixWhereCondition() (:145) then applies with escaping disabled: $this->db->where($value, null, false). The three sibling fields searchReview/searchCommenter/searchMail all use escape_like_str() (:116, :121, :126); date_start uses nothing. date_start is a live form field (application/views/admin/reviews/list.php:58) whose unfiltered POST array is stored in session prdSearch at ecommercen/eshop/controllers/Adv_product_reviews_admin.php:44. Reachable by every moderation role, including the AUTH_ROLE_PRODUCTS grant #59 added.
  15. Unescaped reflection of all four search fields into HTML attributes. application/views/admin/reviews/list.php:25, 31, 38, 60 emit $records['searchReview'|'searchCommenter'|'searchMail'|'date_start'] raw inside double-quoted value="…" — quote break-out XSS, persisted across requests via the prdSearch session key. Same shape as #580 (email_subjects.php).
  16. Unguarded null dereference on an unknown review id. getProductMailStatus() returns null when the id matches no row (Adv_product_reviews_model.php:43), but checkAndSendEmail() dereferences $reviewMailStatus->is_email_sent with no null check (Adv_product_reviews_admin.php:272). setStatus() calls checkAndSendEmail() at :199 — before its own is_null($customerData) guard at :204 — so GET /eshop/product_reviews_admin/setStatus/{badId}/1 warns on null and then calls sendUserReviewStatusUpdateEmail(null, $status).
  17. The OWNING review's product visibility is scoped for with=product, but the review row itself is not. src/Domains/Product/Review/Repository/RepositoryConfigurator.php gained a visibilityScope on its product relation (#637, :12-14 + :32-37), so a guest GET /rest/product/review?with=product for a review of an inactive or soft-deleted product now serialises product: null instead of leaking the product row. But the review row itself is still returned unscoped — index/show/item are guest in 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 null product relation. 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 by application/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.php
  • tests/Integration/Domains/Product/Review/ServiceTest.php
  • tests/Integration/Domains/Customer/CustomerReview/RepositoryTest.php
  • tests/Integration/Domains/Customer/CustomerReview/ServiceTest.php

Legacy moderation controller and loyalty side-effects:

  • tests/Legacy/Eshop/AdvProductReviewsAdminLoyaltyIdempotencyTest.php (suite Legacy, added by #17) — controller-level, stubbed collaborators, no DB. Covers the transactional award mechanics for both setStatus() and bulkSetStatus(): 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() === false surfacing an error and leaving the review re-claimable, and a Throwable between claim and award triggering an explicit rollback + rethrow (whole-batch rollback in the bulk case).
  • tests/Legacy/Eshop/AdvProductReviewsAdminStatusTransitionsTest.php (suite Legacy, 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 via session->set_userdata while bulk uses set_flashdata.
  • tests/Integration/Legacy/Eshop/AdvLoyaltySavePointsToCustomerTest.php (suite Database, added by #18; 9 tests) — covers Adv_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's claimReviewPointsAward(), 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 (suite Database, added by #18; 12 tests) — covers Adv_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), and setReviewStatus() / setReviewsStatusBatch() writing the active column 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.