PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 10.9.3
WooCommerce v10.9.3
10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Internal / OrderReviews / SubmissionHandler.php
woocommerce / src / Internal / OrderReviews Last commit date
Endpoint.php 1 month ago ItemEligibility.php 4 weeks ago Meta.php 1 month ago Scheduler.php 4 weeks ago StarRating.php 1 month ago SubmissionHandler.php 4 weeks ago
SubmissionHandler.php
411 lines
1 <?php
2 /**
3 * SubmissionHandler class file.
4 */
5
6 declare( strict_types = 1 );
7
8 namespace Automattic\WooCommerce\Internal\OrderReviews;
9
10 use Automattic\WooCommerce\Enums\OrderStatus;
11 use WC_Order;
12
13 /**
14 * Handles the AJAX submission of the Review Order form.
15 *
16 * One comment per rated row, with per-row outcome reported back so a single
17 * row's failure cannot block the rest. Guests submit with the order key;
18 * logged-in customers must own the order.
19 *
20 * @internal Just for internal use.
21 *
22 * @since 10.8.0
23 */
24 class SubmissionHandler {
25
26 /**
27 * Action name registered with admin-ajax.
28 */
29 public const ACTION = 'woocommerce_submit_order_reviews';
30
31 /**
32 * Order meta stamped with the time the Review Order page first had no
33 * actionable rows left.
34 *
35 * Set by the submission handler once every eligible item has a review by
36 * this customer (approved or pending moderation), and also by the Endpoint
37 * when the page is loaded with no actionable rows (e.g. all items are
38 * already-reviewed or skipped because reviews are disabled on the products).
39 */
40 public const COMPLETED_META_KEY = '_wc_review_request_completed_at';
41
42 /**
43 * Wire the AJAX endpoints.
44 *
45 * Auto-called by the WC dependency container after instantiation.
46 *
47 * @internal
48 */
49 final public function init(): void {
50 add_action( 'wp_ajax_' . self::ACTION, array( $this, 'handle' ) );
51 add_action( 'wp_ajax_nopriv_' . self::ACTION, array( $this, 'handle' ) );
52 }
53
54 /**
55 * Entry point fired by `admin-ajax.php`.
56 *
57 * Sends a JSON response and exits.
58 */
59 public function handle(): void {
60 // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce is checked below.
61 $order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0;
62 $key = isset( $_POST['key'] ) && is_string( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : '';
63 $nonce = isset( $_POST['_wcnonce'] ) && is_string( $_POST['_wcnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wcnonce'] ) ) : '';
64 // Row-level fields are sanitized inside process_rows(); the array as a whole only needs unslashing.
65 $rows_in = isset( $_POST['reviews'] ) && is_array( $_POST['reviews'] ) ? wp_unslash( $_POST['reviews'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
66 // phpcs:enable WordPress.Security.NonceVerification.Missing
67
68 if ( ! is_string( $nonce ) || ! wp_verify_nonce( $nonce, self::ACTION ) ) {
69 wp_send_json_error( array( 'message' => __( 'Security check failed.', 'woocommerce' ) ), 403 );
70 }
71
72 $order = $order_id ? wc_get_order( $order_id ) : false;
73 if ( ! $order instanceof WC_Order ) {
74 wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 );
75 }
76
77 if ( '' === $key || ! hash_equals( $order->get_order_key(), $key ) ) {
78 wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 );
79 }
80
81 // Logged-in user must own the order. Guests with the right key still pass.
82 if ( $order->get_customer_id() && is_user_logged_in() && get_current_user_id() !== $order->get_customer_id() ) {
83 wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 );
84 }
85
86 // Reuse the same eligibility filter the page-load endpoint uses so the
87 // submit path can never run on an order whose status no longer permits it.
88 // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- documented on Endpoint::is_authorised().
89 $eligible_statuses = (array) apply_filters(
90 'woocommerce_review_order_eligible_statuses',
91 array( OrderStatus::COMPLETED ),
92 $order
93 );
94
95 if ( ! in_array( $order->get_status(), $eligible_statuses, true ) ) {
96 wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 );
97 }
98
99 $results = $this->process_rows( $order, $rows_in );
100
101 $this->maybe_mark_order_complete( $order );
102
103 /**
104 * Fires after the Review Order form has been processed.
105 *
106 * @since 10.8.0
107 *
108 * @param WC_Order $order The order.
109 * @param array $results Per-row outcomes — see `SubmissionHandler::process_rows()`.
110 */
111 do_action( 'woocommerce_review_order_submitted', $order, $results );
112
113 wp_send_json_success( array( 'results' => $results ) );
114 }
115
116 /**
117 * Process the submitted row payload and return per-row outcomes.
118 *
119 * @param WC_Order $order Order being reviewed.
120 * @param array $rows_in Raw `$_POST['reviews']` value.
121 * @return array<int, array{product_id:int, status:string, comment_id?:int, error?:string}>
122 */
123 private function process_rows( WC_Order $order, array $rows_in ): array {
124 $results = array();
125 $item_index = $this->index_eligible_order_items( $order );
126 $author_name = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() );
127 $author_email = $order->get_billing_email();
128 $author_ip = $order->get_customer_ip_address();
129 $author_agent = $order->get_customer_user_agent();
130 $require_mod = (bool) get_option( 'comment_moderation' );
131
132 // Drop any per-request memoisation a prior caller may have populated,
133 // then preload the eligibility cache so the per-row decide() calls
134 // below don't issue one already-reviewed query each. Reset matters
135 // inside the suite (multiple submissions in one PHP process) and is
136 // a no-op in production (admin-ajax runs in a fresh process).
137 ItemEligibility::reset_cache();
138 ItemEligibility::preload_for_items( $item_index, $order );
139
140 foreach ( $rows_in as $row_index => $row ) {
141 $row_index = (int) $row_index;
142 $row = is_array( $row ) ? $row : array();
143
144 $rating = isset( $row['rating'] ) ? (int) $row['rating'] : 0;
145 if ( 0 === $rating ) {
146 // Empty rating means the customer chose to skip this row; allowed.
147 continue;
148 }
149
150 $product_id = isset( $row['product_id'] ) ? absint( $row['product_id'] ) : 0;
151 $order_item_id = isset( $row['order_item_id'] ) ? absint( $row['order_item_id'] ) : 0;
152 // $rows_in was already unslashed in handle(); avoid double-unslashing.
153 $text = isset( $row['text'] ) && is_string( $row['text'] ) ? trim( wp_kses_post( $row['text'] ) ) : '';
154
155 // Per-row result always carries `product_id` (parent product, where
156 // the review lives) and `variation_id` (0 for simple products) so
157 // callers don't have to know whether the client posted the parent
158 // or the variation id as `product_id`. Both are echoed back as
159 // soon as we resolve the line item; for early validation failures
160 // they reflect the raw submitted product id with `variation_id: 0`.
161 $result = array(
162 'product_id' => $product_id,
163 'variation_id' => 0,
164 'status' => 'error',
165 );
166
167 if ( $rating < 1 || $rating > 5 ) {
168 $result['error'] = 'invalid_rating';
169 $results[ $row_index ] = $result;
170 continue;
171 }
172
173 // invalid_row also covers fully-refunded line items: index_eligible_order_items()
174 // runs them through woocommerce_review_order_eligible_items, which strips them.
175 if ( ! $product_id || ! $order_item_id || ! isset( $item_index[ $order_item_id ] ) ) {
176 $result['error'] = 'invalid_row';
177 $results[ $row_index ] = $result;
178 continue;
179 }
180
181 $item = $item_index[ $order_item_id ];
182
183 // Variable products: the row template posts the variation id,
184 // while $item->get_product_id() returns the parent. Accept either.
185 $line_product_id = (int) $item->get_product_id();
186 $line_variation_id = (int) $item->get_variation_id();
187 if ( $product_id !== $line_product_id && $product_id !== $line_variation_id ) {
188 $result['error'] = 'product_mismatch';
189 $results[ $row_index ] = $result;
190 continue;
191 }
192
193 // Canonicalise the result fields now that we've resolved the line
194 // item: parent product id + the line's variation id (0 for simple).
195 $result['product_id'] = $line_product_id;
196 $result['variation_id'] = $line_variation_id;
197
198 // Reviews always attach to the parent product so they show on the
199 // product page regardless of which variation was bought.
200 $review_post_id = $line_product_id;
201
202 // Reject submissions for products whose review form was never
203 // rendered (comments disabled on the product).
204 $decision = ItemEligibility::decide( $item, $order );
205 if ( ItemEligibility::STATUS_SKIP === $decision['status'] ) {
206 $result['error'] = 'reviews_not_open';
207 $results[ $row_index ] = $result;
208 continue;
209 }
210
211 // Only attribute the comment to a WP user when the current request is
212 // authenticated as that user. Guests reaching the page via the order
213 // key are not authenticated, so the comment stays unattributed (0).
214 $customer_id = (int) $order->get_customer_id();
215 $current_user_id = get_current_user_id();
216 $comment_user_id = ( $current_user_id > 0 && $current_user_id === $customer_id ) ? $current_user_id : 0;
217
218 // If the customer already has a review tied to this order for this
219 // product, update it in place instead of stacking duplicates. The
220 // existing comment id comes from the server-side lookup, not the
221 // client, so a tampered POST can't target someone else's review.
222 $existing = $decision['comment'] instanceof \WP_Comment ? $decision['comment'] : null;
223
224 if ( $existing instanceof \WP_Comment ) {
225 $update_ok = wp_update_comment(
226 wp_slash(
227 array(
228 'comment_ID' => (int) $existing->comment_ID,
229 'comment_content' => $text,
230 'comment_approved' => $require_mod ? 0 : 1,
231 )
232 )
233 );
234 if ( false === $update_ok || is_wp_error( $update_ok ) ) {
235 $result['error'] = 'update_failed';
236 $results[ $row_index ] = $result;
237 continue;
238 }
239
240 update_comment_meta( (int) $existing->comment_ID, 'rating', $rating );
241
242 $result['comment_id'] = (int) $existing->comment_ID;
243 $result['status'] = $require_mod ? 'pending_moderation' : 'ok';
244 $results[ $row_index ] = $result;
245 continue;
246 }
247
248 $comment_data = array(
249 'comment_post_ID' => $review_post_id,
250 'comment_author' => '' !== $author_name ? $author_name : __( 'Anonymous', 'woocommerce' ),
251 'comment_author_email' => $author_email,
252 'comment_author_IP' => $author_ip,
253 'comment_agent' => $author_agent,
254 'comment_content' => $text,
255 'comment_type' => 'review',
256 'comment_approved' => $require_mod ? 0 : 1,
257 'user_id' => $comment_user_id,
258 );
259
260 $comment_id = wp_insert_comment( wp_slash( $comment_data ) );
261 if ( ! $comment_id ) {
262 $result['error'] = 'insert_failed';
263 $results[ $row_index ] = $result;
264 continue;
265 }
266
267 add_comment_meta( $comment_id, 'rating', $rating, true );
268 add_comment_meta( $comment_id, 'verified', 1, true );
269 add_comment_meta( $comment_id, ItemEligibility::ORDER_META_KEY, (int) $order->get_id(), true );
270 add_comment_meta( $comment_id, ItemEligibility::VARIATION_META_KEY, $line_variation_id, true );
271
272 $variation_summary = ItemEligibility::format_variation_summary( $item );
273 if ( '' !== $variation_summary ) {
274 add_comment_meta( $comment_id, ItemEligibility::VARIATION_SUMMARY_META_KEY, $variation_summary, true );
275 }
276
277 $result['comment_id'] = (int) $comment_id;
278 $result['status'] = $require_mod ? 'pending_moderation' : 'ok';
279 $results[ $row_index ] = $result;
280 }//end foreach
281
282 return $results;
283 }
284
285 /**
286 * Set the completed-at meta when every eligible item has a review by this
287 * customer (approved or pending moderation), whether posted in this
288 * submission or an earlier one. Spam/trash comments are excluded.
289 *
290 * @param WC_Order $order Order being reviewed.
291 */
292 private function maybe_mark_order_complete( WC_Order $order ): void {
293 // Recording the moment the order first became fully reviewed; never overwrite.
294 if ( $order->get_meta( self::COMPLETED_META_KEY ) ) {
295 return;
296 }
297
298 $customer_email = $order->get_billing_email();
299 if ( '' === $customer_email ) {
300 return;
301 }
302
303 // Build the same eligible-row set the page uses, then collect the
304 // distinct (parent product, variation) slots that need a review.
305 // Counting by slot rather than per-line-item means a double-submit of
306 // the same variation can't satisfy a sibling variation's quota, and
307 // the same simple product appearing on multiple rows still only
308 // needs one review (the page collapses those rows anyway).
309 // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- documented at the page-template invocation site.
310 $eligible_items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order );
311
312 $required_slots = array();
313 $product_ids = array();
314 foreach ( $eligible_items as $item ) {
315 if ( ! $item instanceof \WC_Order_Item_Product ) {
316 continue;
317 }
318 $product_id = (int) $item->get_product_id();
319 $variation_id = (int) $item->get_variation_id();
320 if ( $product_id > 0 ) {
321 $required_slots[ $product_id . '|' . $variation_id ] = true;
322 $product_ids[ $product_id ] = $product_id;
323 }
324 }
325
326 if ( empty( $required_slots ) ) {
327 return;
328 }
329
330 // Single grouped lookup, fetching the comment objects directly so we
331 // can read comment_post_ID without a follow-up query per row. Limit
332 // to approved + pending-moderation so spam/trash never count as
333 // completion, AND to reviews tagged with this order so an older
334 // review of the same parent product from a previous order doesn't
335 // satisfy the per-row count for the current one. number=>0 disables
336 // the default 20-row cap so this still works for orders with many
337 // reviewable items.
338 $comments = get_comments(
339 array(
340 'post__in' => array_values( $product_ids ),
341 'author_email' => $customer_email,
342 'type' => 'review',
343 'status' => array( 'approve', 'hold' ),
344 'number' => 0,
345 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded by post__in + author_email.
346 array(
347 'key' => ItemEligibility::ORDER_META_KEY,
348 'value' => (string) $order->get_id(),
349 ),
350 ),
351 )
352 );
353
354 if ( ! is_array( $comments ) || empty( $comments ) ) {
355 return;
356 }
357
358 // Index reviewed slots by (parent_id, variation_id); duplicate comments
359 // for the same slot still count as one toward completion.
360 $reviewed_slots = array();
361 foreach ( $comments as $comment ) {
362 if ( $comment instanceof \WP_Comment ) {
363 $slot_key = (int) $comment->comment_post_ID . '|' . (int) get_comment_meta( (int) $comment->comment_ID, ItemEligibility::VARIATION_META_KEY, true );
364 $reviewed_slots[ $slot_key ] = true;
365 }
366 }
367
368 foreach ( $required_slots as $slot_key => $_ ) {
369 if ( ! isset( $reviewed_slots[ $slot_key ] ) ) {
370 return;
371 }
372 }
373
374 $order->update_meta_data( self::COMPLETED_META_KEY, (string) time() );
375 $order->save();
376 }
377
378 /**
379 * Map order_item_id => `WC_Order_Item_Product` for fast row lookup,
380 * filtered through `woocommerce_review_order_eligible_items` so the
381 * handler agrees with the page on which items are reviewable. The
382 * default callback excludes fully-refunded items.
383 *
384 * @param WC_Order $order Order being reviewed.
385 * @return array<int, \WC_Order_Item_Product>
386 */
387 private function index_eligible_order_items( WC_Order $order ): array {
388 /**
389 * Filter the eligible items considered by the Review Order
390 * submission handler.
391 *
392 * Same hook the page uses; documented in
393 * `templates/order/customer-review-order.php`.
394 *
395 * @since 10.8.0
396 *
397 * @param \WC_Order_Item[] $items Order line items.
398 * @param WC_Order $order The order being reviewed.
399 */
400 $items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order );
401
402 $index = array();
403 foreach ( $items as $item ) {
404 if ( $item instanceof \WC_Order_Item_Product ) {
405 $index[ $item->get_id() ] = $item;
406 }
407 }
408 return $index;
409 }
410 }
411