PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 11.1.0
WooCommerce v11.1.0
11.1.0 11.1.0-rc.2 11.1.0-rc.1 11.1.0-beta.2 11.1.0-beta.1 11.0.1 11.0.0 11.0.0-rc.3 11.0.0-rc.2 11.0.0-rc.1 11.0.0-beta.2 11.0.0-beta.1 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 2 weeks ago ItemEligibility.php 2 months ago Meta.php 3 months ago Scheduler.php 2 months ago StarRating.php 3 months ago SubmissionHandler.php 2 weeks ago
SubmissionHandler.php
491 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
131 // Drop any per-request memoisation a prior caller may have populated,
132 // then preload the eligibility cache so the per-row decide() calls
133 // below don't issue one already-reviewed query each. Reset matters
134 // inside the suite (multiple submissions in one PHP process) and is
135 // a no-op in production (admin-ajax runs in a fresh process).
136 ItemEligibility::reset_cache();
137 ItemEligibility::preload_for_items( $item_index, $order );
138
139 foreach ( $rows_in as $row_index => $row ) {
140 $row_index = (int) $row_index;
141 $row = is_array( $row ) ? $row : array();
142
143 $rating = isset( $row['rating'] ) ? (int) $row['rating'] : 0;
144 if ( 0 === $rating ) {
145 // Empty rating means the customer chose to skip this row; allowed.
146 continue;
147 }
148
149 $product_id = isset( $row['product_id'] ) ? absint( $row['product_id'] ) : 0;
150 $order_item_id = isset( $row['order_item_id'] ) ? absint( $row['order_item_id'] ) : 0;
151 // $rows_in was already unslashed in handle(); avoid double-unslashing.
152 $text = isset( $row['text'] ) && is_string( $row['text'] ) ? trim( wp_kses_post( $row['text'] ) ) : '';
153
154 // Per-row result always carries `product_id` (parent product, where
155 // the review lives) and `variation_id` (0 for simple products) so
156 // callers don't have to know whether the client posted the parent
157 // or the variation id as `product_id`. Both are echoed back as
158 // soon as we resolve the line item; for early validation failures
159 // they reflect the raw submitted product id with `variation_id: 0`.
160 $result = array(
161 'product_id' => $product_id,
162 'variation_id' => 0,
163 'status' => 'error',
164 );
165
166 if ( $rating < 1 || $rating > 5 ) {
167 $result['error'] = 'invalid_rating';
168 $results[ $row_index ] = $result;
169 continue;
170 }
171
172 // invalid_row also covers fully-refunded line items: index_eligible_order_items()
173 // runs them through woocommerce_review_order_eligible_items, which strips them.
174 if ( ! $product_id || ! $order_item_id || ! isset( $item_index[ $order_item_id ] ) ) {
175 $result['error'] = 'invalid_row';
176 $results[ $row_index ] = $result;
177 continue;
178 }
179
180 $item = $item_index[ $order_item_id ];
181
182 // Variable products: the row template posts the variation id,
183 // while $item->get_product_id() returns the parent. Accept either.
184 $line_product_id = (int) $item->get_product_id();
185 $line_variation_id = (int) $item->get_variation_id();
186 if ( $product_id !== $line_product_id && $product_id !== $line_variation_id ) {
187 $result['error'] = 'product_mismatch';
188 $results[ $row_index ] = $result;
189 continue;
190 }
191
192 // Canonicalise the result fields now that we've resolved the line
193 // item: parent product id + the line's variation id (0 for simple).
194 $result['product_id'] = $line_product_id;
195 $result['variation_id'] = $line_variation_id;
196
197 // Reviews always attach to the parent product so they show on the
198 // product page regardless of which variation was bought.
199 $review_post_id = $line_product_id;
200
201 // Reject submissions for products whose review form was never
202 // rendered (comments disabled on the product).
203 $decision = ItemEligibility::decide( $item, $order );
204 if ( ItemEligibility::STATUS_SKIP === $decision['status'] ) {
205 $result['error'] = 'reviews_not_open';
206 $results[ $row_index ] = $result;
207 continue;
208 }
209
210 // Only attribute the comment to a WP user when the current request is
211 // authenticated as that user. Guests reaching the page via the order
212 // key are not authenticated, so the comment stays unattributed (0).
213 $customer_id = (int) $order->get_customer_id();
214 $current_user_id = get_current_user_id();
215 $comment_user_id = ( $current_user_id > 0 && $current_user_id === $customer_id ) ? $current_user_id : 0;
216
217 // If the customer already has a review tied to this order for this
218 // product, update it in place instead of stacking duplicates. The
219 // existing comment id comes from the server-side lookup, not the
220 // client, so a tampered POST can't target someone else's review.
221 $existing = $decision['comment'] instanceof \WP_Comment ? $decision['comment'] : null;
222
223 if ( $existing instanceof \WP_Comment ) {
224 // A moderator's spam/trash decision is final.
225 if ( in_array( wp_get_comment_status( $existing ), array( 'spam', 'trash' ), true ) ) {
226 $result['error'] = 'update_failed';
227 $results[ $row_index ] = $result;
228 continue;
229 }
230
231 $approved = self::comment_approval_status( $author_name, $author_email, $text, $author_ip, $author_agent );
232 $update_ok = wp_update_comment(
233 wp_slash(
234 array(
235 'comment_ID' => (int) $existing->comment_ID,
236 'comment_content' => $text,
237 'comment_approved' => $approved,
238 )
239 )
240 );
241 if ( false === $update_ok || is_wp_error( $update_ok ) ) {
242 $result['error'] = 'update_failed';
243 $results[ $row_index ] = $result;
244 continue;
245 }
246
247 update_comment_meta( (int) $existing->comment_ID, 'rating', $rating );
248
249 $result['comment_id'] = (int) $existing->comment_ID;
250 $result['status'] = 1 === $approved ? 'ok' : 'pending_moderation';
251 $results[ $row_index ] = $result;
252 continue;
253 }
254
255 if ( self::has_rejected_review( $order, $line_product_id, $line_variation_id ) ) {
256 $result['error'] = 'update_failed';
257 $results[ $row_index ] = $result;
258 continue;
259 }
260
261 $approved = self::comment_approval_status( $author_name, $author_email, $text, $author_ip, $author_agent );
262
263 $comment_data = array(
264 'comment_post_ID' => $review_post_id,
265 'comment_author' => '' !== $author_name ? $author_name : __( 'Anonymous', 'woocommerce' ),
266 'comment_author_email' => $author_email,
267 'comment_author_IP' => $author_ip,
268 'comment_agent' => $author_agent,
269 'comment_content' => $text,
270 'comment_type' => 'review',
271 'comment_approved' => $approved,
272 'user_id' => $comment_user_id,
273 );
274
275 $comment_id = wp_insert_comment( wp_slash( $comment_data ) );
276 if ( ! $comment_id ) {
277 $result['error'] = 'insert_failed';
278 $results[ $row_index ] = $result;
279 continue;
280 }
281
282 add_comment_meta( $comment_id, 'rating', $rating, true );
283 add_comment_meta( $comment_id, 'verified', 1, true );
284 add_comment_meta( $comment_id, ItemEligibility::ORDER_META_KEY, (int) $order->get_id(), true );
285 add_comment_meta( $comment_id, ItemEligibility::VARIATION_META_KEY, $line_variation_id, true );
286
287 $variation_summary = ItemEligibility::format_variation_summary( $item );
288 if ( '' !== $variation_summary ) {
289 add_comment_meta( $comment_id, ItemEligibility::VARIATION_SUMMARY_META_KEY, $variation_summary, true );
290 }
291
292 $result['comment_id'] = (int) $comment_id;
293 $result['status'] = 1 === $approved ? 'ok' : 'pending_moderation';
294 $results[ $row_index ] = $result;
295 }//end foreach
296
297 return $results;
298 }
299
300 /**
301 * Decide whether a review should be auto-approved, via WordPress's own `check_comment()`.
302 *
303 * @param string $author Comment author name.
304 * @param string $email Comment author email.
305 * @param string $content Comment content.
306 * @param string $ip Comment author IP.
307 * @param string $agent Comment author user agent.
308 * @return int 1 to auto-approve, 0 to hold for moderation.
309 */
310 private static function comment_approval_status( string $author, string $email, string $content, string $ip, string $agent ): int {
311 add_filter( 'pre_option_comment_previously_approved', '__return_zero' );
312 $approved = check_comment( $author, $email, '', $content, $ip, $agent, 'review' );
313 remove_filter( 'pre_option_comment_previously_approved', '__return_zero' );
314
315 return $approved ? 1 : 0;
316 }
317
318 /**
319 * Whether a moderator already marked this exact order/product/variation
320 * review as spam or trash.
321 *
322 * @param WC_Order $order Order being reviewed.
323 * @param int $product_id Parent product id.
324 * @param int $variation_id Variation id (0 for simple products).
325 * @return bool
326 */
327 private static function has_rejected_review( WC_Order $order, int $product_id, int $variation_id ): bool {
328 $email = $order->get_billing_email();
329 if ( '' === $email ) {
330 return false;
331 }
332
333 $comments = get_comments(
334 array(
335 'post_id' => $product_id,
336 'author_email' => $email,
337 'type' => 'review',
338 'status' => array( 'spam', 'trash' ),
339 'number' => 1,
340 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded by post_id + author_email.
341 'relation' => 'AND',
342 array(
343 'key' => ItemEligibility::ORDER_META_KEY,
344 'value' => (string) $order->get_id(),
345 ),
346 array(
347 'relation' => 'OR',
348 array(
349 'key' => ItemEligibility::VARIATION_META_KEY,
350 'value' => (string) $variation_id,
351 ),
352 array(
353 // Pre-10.9.0 reviews have no VARIATION_META_KEY row at all.
354 'key' => ItemEligibility::VARIATION_META_KEY,
355 'compare' => 'NOT EXISTS',
356 ),
357 ),
358 ),
359 )
360 );
361
362 return is_array( $comments ) && ! empty( $comments );
363 }
364
365 /**
366 * Set the completed-at meta when every eligible item has a review by this
367 * customer (approved or pending moderation), whether posted in this
368 * submission or an earlier one. Spam/trash comments are excluded.
369 *
370 * @param WC_Order $order Order being reviewed.
371 */
372 private function maybe_mark_order_complete( WC_Order $order ): void {
373 // Recording the moment the order first became fully reviewed; never overwrite.
374 if ( $order->get_meta( self::COMPLETED_META_KEY ) ) {
375 return;
376 }
377
378 $customer_email = $order->get_billing_email();
379 if ( '' === $customer_email ) {
380 return;
381 }
382
383 // Build the same eligible-row set the page uses, then collect the
384 // distinct (parent product, variation) slots that need a review.
385 // Counting by slot rather than per-line-item means a double-submit of
386 // the same variation can't satisfy a sibling variation's quota, and
387 // the same simple product appearing on multiple rows still only
388 // needs one review (the page collapses those rows anyway).
389 // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- documented at the page-template invocation site.
390 $eligible_items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order );
391
392 $required_slots = array();
393 $product_ids = array();
394 foreach ( $eligible_items as $item ) {
395 if ( ! $item instanceof \WC_Order_Item_Product ) {
396 continue;
397 }
398 $product_id = (int) $item->get_product_id();
399 $variation_id = (int) $item->get_variation_id();
400 if ( $product_id > 0 ) {
401 $required_slots[ $product_id . '|' . $variation_id ] = true;
402 $product_ids[ $product_id ] = $product_id;
403 }
404 }
405
406 if ( empty( $required_slots ) ) {
407 return;
408 }
409
410 // Single grouped lookup, fetching the comment objects directly so we
411 // can read comment_post_ID without a follow-up query per row. Limit
412 // to approved + pending-moderation so spam/trash never count as
413 // completion, AND to reviews tagged with this order so an older
414 // review of the same parent product from a previous order doesn't
415 // satisfy the per-row count for the current one. number=>0 disables
416 // the default 20-row cap so this still works for orders with many
417 // reviewable items.
418 $comments = get_comments(
419 array(
420 'post__in' => array_values( $product_ids ),
421 'author_email' => $customer_email,
422 'type' => 'review',
423 'status' => array( 'approve', 'hold' ),
424 'number' => 0,
425 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded by post__in + author_email.
426 array(
427 'key' => ItemEligibility::ORDER_META_KEY,
428 'value' => (string) $order->get_id(),
429 ),
430 ),
431 )
432 );
433
434 if ( ! is_array( $comments ) || empty( $comments ) ) {
435 return;
436 }
437
438 // Index reviewed slots by (parent_id, variation_id); duplicate comments
439 // for the same slot still count as one toward completion.
440 $reviewed_slots = array();
441 foreach ( $comments as $comment ) {
442 if ( $comment instanceof \WP_Comment ) {
443 $slot_key = (int) $comment->comment_post_ID . '|' . (int) get_comment_meta( (int) $comment->comment_ID, ItemEligibility::VARIATION_META_KEY, true );
444 $reviewed_slots[ $slot_key ] = true;
445 }
446 }
447
448 foreach ( $required_slots as $slot_key => $_ ) {
449 if ( ! isset( $reviewed_slots[ $slot_key ] ) ) {
450 return;
451 }
452 }
453
454 $order->update_meta_data( self::COMPLETED_META_KEY, (string) time() );
455 $order->save();
456 }
457
458 /**
459 * Map order_item_id => `WC_Order_Item_Product` for fast row lookup,
460 * filtered through `woocommerce_review_order_eligible_items` so the
461 * handler agrees with the page on which items are reviewable. The
462 * default callback excludes fully-refunded items.
463 *
464 * @param WC_Order $order Order being reviewed.
465 * @return array<int, \WC_Order_Item_Product>
466 */
467 private function index_eligible_order_items( WC_Order $order ): array {
468 /**
469 * Filter the eligible items considered by the Review Order
470 * submission handler.
471 *
472 * Same hook the page uses; documented in
473 * `templates/order/customer-review-order.php`.
474 *
475 * @since 10.8.0
476 *
477 * @param \WC_Order_Item[] $items Order line items.
478 * @param WC_Order $order The order being reviewed.
479 */
480 $items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order );
481
482 $index = array();
483 foreach ( $items as $item ) {
484 if ( $item instanceof \WC_Order_Item_Product ) {
485 $index[ $item->get_id() ] = $item;
486 }
487 }
488 return $index;
489 }
490 }
491