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 |