PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
1.1.12 1.1.11 1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 All 36 releases
desktop-mode / includes / my-wordpress / integrations / woocommerce-relations.php

woocommerce-relations.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.12, at includes/my-wordpress/integrations/woocommerce-relations.php

1,199 lines 39.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — My WordPress: WooCommerce × the relations layer.
4 *
5 * An order is the most connected object in WordPress and the least
6 * connected screen. It names a customer, some products and maybe a
7 * coupon, and every one of those is a dead end: WooCommerce prints the
8 * customer's name as text, the line items as text, the coupon as a
9 * token. To go from an order to the product it sold you go back to the
10 * catalogue and search for it.
11 *
12 * The shell already knows how to express that. Two surfaces, both
13 * public, neither WooCommerce-specific:
14 *
15 * 1. **Content identity** (`openstation_window_content_identity`) —
16 * what a window is showing, plus the objects it refers to. Two
17 * open windows whose identities meet get a drawn tie on the
18 * desktop. Open an order beside the product it sold and the line
19 * between them is the shell telling you they are the same story.
20 *
21 * 2. **Related entities** (`openstation_window_related_entities`) —
22 * the title bar's "Related" menu. One click from the order to the
23 * customer's profile, to any product on it, to the coupon that
24 * discounted it, each opening as its own window rather than
25 * navigating away from what you were reading.
26 *
27 * Both run inside the chromeless iframe — real admin context — so the
28 * relations resolve against live WooCommerce objects rather than
29 * against a URL we guessed at.
30 *
31 * Screens covered:
32 *
33 * - Order edit, both storages. High-Performance Order Storage moves
34 * the screen to `admin.php?page=wc-orders&action=edit&id=N`, where
35 * the built-in `post.php` detection can never see it; legacy
36 * storage lands on `post.php` and gets an identity already, but
37 * one with no links on it.
38 * - Product edit — categories, tags, reviews, and the media the
39 * built-in extractor already finds.
40 * - Coupon edit — the products and categories it is restricted to,
41 * which WooCommerce shows as bare token fields you have to click
42 * into to read.
43 * - User edit — a customer's orders, when the viewer may see them.
44 *
45 * Everything here is inert without WooCommerce.
46 *
47 * @package OpenStation
48 */
49
50 defined( 'ABSPATH' ) || exit;
51
52 /**
53 * How many line items / coupons one order announces.
54 *
55 * The relations engine caps a ref's whole `links` array at 64 and its
56 * `related` list at 64; a 200-line wholesale order would spend the
57 * entire budget on products and silently drop the customer. Bounded
58 * here so the trailing groups always survive.
59 */
60 const OPENSTATION_WOO_RELATION_ITEM_CAP = 20;
61
62 /**
63 * How many orders the product and coupon groups list.
64 */
65 const OPENSTATION_WOO_RELATION_ORDER_CAP = 10;
66
67 /**
68 * How many order-item rows to read to fill that list.
69 *
70 * The id lists come out of `woocommerce_order_items`, which holds
71 * refund rows alongside order rows — and refunds sort *first* there,
72 * since the query orders by descending id and a refund is created
73 * after the order it refunds. A `LIMIT 10` on a much-refunded product
74 * could therefore come back as ten refunds and no orders at all, and
75 * the group would render empty on the one product whose history a
76 * merchant most wants to read. Reading a few times the budget and
77 * stopping at the cap costs one bounded query.
78 */
79 const OPENSTATION_WOO_RELATION_ORDER_CANDIDATES = 40;
80
81 /**
82 * Query flag marking a person-URL as a request for a *particular*
83 * view of that person rather than for the profile editor.
84 *
85 * Must stay equal to `OS_PERSON_VIEW_PARAM` in
86 * `src/native-url-remap.ts` — the URL is built here and read there,
87 * so the two ends have to agree on the literal.
88 */
89 const OPENSTATION_PERSON_VIEW_PARAM = 'os_person_view';
90
91 /*
92 -------------------------------------------------------------------
93 * Screen resolution
94 * ----------------------------------------------------------------
95 */
96
97 /**
98 * The order the current admin screen is editing, whichever storage
99 * the store uses.
100 *
101 * @return WC_Abstract_Order|null
102 */
103 function openstation_my_wordpress_woo_current_order() {
104 if ( ! openstation_my_wordpress_woo_active() ) {
105 return null;
106 }
107
108 $pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
109
110 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
111 $id = 0;
112 if ( 'admin.php' === $pagenow ) {
113 // HPOS. `wc-orders` for shop orders, `wc-orders--{type}` for
114 // custom order types (subscriptions and friends) — both are
115 // orders as far as the relations layer cares.
116 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
117 if ( 0 !== strpos( $page, 'wc-orders' ) ) {
118 return null;
119 }
120 $action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : '';
121 if ( 'edit' !== $action ) {
122 return null;
123 }
124 $id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0;
125 } elseif ( 'post.php' === $pagenow ) {
126 // Legacy storage: orders are posts.
127 $id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0;
128 if ( $id > 0 && 'shop_order' !== get_post_type( $id ) ) {
129 return null;
130 }
131 }
132 // phpcs:enable WordPress.Security.NonceVerification.Recommended
133
134 if ( $id <= 0 ) {
135 return null;
136 }
137
138 $order = wc_get_order( $id );
139
140 return $order instanceof WC_Abstract_Order ? $order : null;
141 }
142
143 /**
144 * Whether the viewer may see this order at all.
145 *
146 * @return bool
147 */
148 function openstation_my_wordpress_woo_can_read_orders() {
149 return true === openstation_my_wordpress_woo_orders_permission();
150 }
151
152 /**
153 * Whether an object read back from an order-item row is a purchase.
154 *
155 * Refunds keep their own line items in the same
156 * `woocommerce_order_items` tables, under the refund's id — so a
157 * lookup that asks those tables "which orders contain product X"
158 * answers with refund ids too, for any product that has ever been
159 * refunded. `WC_Order_Refund` extends `WC_Abstract_Order`, so the
160 * usual guard waves it through, and the next line asks it for
161 * `get_order_number()`: a `WC_Order` method the abstract base does
162 * not declare, and therefore a fatal on the product edit screen.
163 *
164 * Dropping refunds is also the truer answer. "Who bought this" and
165 * "where was this coupon used" are questions about purchases, and a
166 * refund is the undoing of one.
167 *
168 * Deliberately *not* `instanceof WC_Order`. The abstract base is the
169 * type every order class actually extends, including HPOS's overrides
170 * and whatever custom order type a store registers — testing against
171 * `WC_Order` has already been tried elsewhere in this integration and
172 * silently emptied lists on stores that use them. So this excludes the
173 * one known-hostile subclass and then asks the object directly for the
174 * accessors these lists call, which keeps an exotic order type that
175 * extends the base without them out of a fatal too.
176 *
177 * @param mixed $order Whatever `wc_get_order()` returned.
178 * @return bool
179 */
180 function openstation_my_wordpress_woo_is_purchase( $order ) {
181 if ( ! $order instanceof WC_Abstract_Order ) {
182 return false;
183 }
184 if ( $order instanceof WC_Order_Refund ) {
185 return false;
186 }
187 return method_exists( $order, 'get_order_number' );
188 }
189
190 /**
191 * The content identity for WooCommerce's product-reviews screen when
192 * it is filtered to a single product.
193 *
194 * `edit.php?post_type=product&page=product-reviews&product_id=N`.
195 * The unfiltered all-reviews list stays identity-less, the same way
196 * core leaves the unfiltered comments list alone: a window showing
197 * everything belongs to nothing in particular.
198 *
199 * @return array|null
200 */
201 function openstation_my_wordpress_woo_reviews_identity() {
202 $pagenow = isset( $GLOBALS['pagenow'] ) ? (string) $GLOBALS['pagenow'] : '';
203 if ( 'edit.php' !== $pagenow ) {
204 return null;
205 }
206
207 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only identity harvest; the host admin page enforces capability + nonce.
208 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
209 if ( 'product-reviews' !== $page ) {
210 return null;
211 }
212 $product_id = isset( $_GET['product_id'] ) ? absint( $_GET['product_id'] ) : 0;
213 // phpcs:enable WordPress.Security.NonceVerification.Recommended
214
215 if ( $product_id <= 0 || 'product' !== get_post_type( $product_id ) ) {
216 return null;
217 }
218 if ( ! current_user_can( 'edit_post', $product_id ) ) {
219 return null;
220 }
221
222 return array(
223 'type' => 'reviews',
224 'id' => $product_id,
225 /* translators: %s: product name. */
226 'label' => sprintf( __( 'Reviews of %s', 'desktop-mode' ), get_the_title( $product_id ) ),
227 'root' => array(
228 'type' => 'product',
229 'id' => $product_id,
230 ),
231 );
232 }
233
234 /*
235 -------------------------------------------------------------------
236 * Content identity
237 * ----------------------------------------------------------------
238 */
239
240 /**
241 * The objects an order refers to — its customer, its products, its
242 * coupons — as relation refs.
243 *
244 * Direction is `references` throughout: the order points at them. A
245 * product does not belong to an order (it outlives it), and a customer
246 * certainly doesn't, so `child` would be a lie the arrowheads would
247 * then tell on screen.
248 *
249 * @param WC_Abstract_Order $order Order.
250 * @return array[] Ref entries for the identity's `links` array.
251 */
252 function openstation_my_wordpress_woo_order_refs( $order ) {
253 $links = array();
254 $seen = array();
255
256 $push = static function ( $type, $id ) use ( &$links, &$seen ) {
257 $id = (int) $id;
258 $key = $type . ':' . $id;
259 if ( $id <= 0 || isset( $seen[ $key ] ) || count( $links ) >= 64 ) {
260 return;
261 }
262 $seen[ $key ] = true;
263 $links[] = array(
264 'type' => $type,
265 'id' => $id,
266 );
267 };
268
269 // The customer. `user` is the type the shell's own user-edit
270 // screens announce, so the tie forms against a profile window
271 // opened from anywhere — not just from the shop.
272 $customer_id = method_exists( $order, 'get_customer_id' ) ? (int) $order->get_customer_id() : 0;
273 if ( $customer_id > 0 ) {
274 $push( 'user', $customer_id );
275 }
276
277 $items = 0;
278 foreach ( $order->get_items() as $item ) {
279 if ( $items >= OPENSTATION_WOO_RELATION_ITEM_CAP ) {
280 break;
281 }
282 $product_id = method_exists( $item, 'get_product_id' ) ? (int) $item->get_product_id() : 0;
283 if ( $product_id > 0 && 'product' === get_post_type( $product_id ) ) {
284 $push( 'product', $product_id );
285 ++$items;
286 }
287 }
288
289 $coupons = 0;
290 foreach ( $order->get_items( 'coupon' ) as $line ) {
291 if ( $coupons >= OPENSTATION_WOO_RELATION_ITEM_CAP ) {
292 break;
293 }
294 $coupon = new WC_Coupon( $line->get_code() );
295 if ( $coupon->get_id() ) {
296 $push( 'shop_coupon', $coupon->get_id() );
297 ++$coupons;
298 }
299 }
300
301 return $links;
302 }
303
304 /**
305 * The objects a coupon refers to — the products and categories it is
306 * restricted to.
307 *
308 * @param WC_Coupon $coupon Coupon.
309 * @return array[]
310 */
311 function openstation_my_wordpress_woo_coupon_refs( $coupon ) {
312 $links = array();
313
314 foreach ( array_slice( (array) $coupon->get_product_ids(), 0, OPENSTATION_WOO_RELATION_ITEM_CAP ) as $product_id ) {
315 $product_id = (int) $product_id;
316 if ( $product_id > 0 && 'product' === get_post_type( $product_id ) ) {
317 $links[] = array(
318 'type' => 'product',
319 'id' => $product_id,
320 );
321 }
322 }
323
324 foreach ( array_slice( (array) $coupon->get_product_categories(), 0, OPENSTATION_WOO_RELATION_ITEM_CAP ) as $term_id ) {
325 $term_id = (int) $term_id;
326 $term = $term_id ? get_term( $term_id, 'product_cat' ) : null;
327 if ( $term instanceof WP_Term ) {
328 $links[] = array(
329 'type' => 'term/product_cat',
330 'id' => $term_id,
331 );
332 }
333 }
334
335 return $links;
336 }
337
338 /**
339 * The human label for an order: its number, plus the billing name
340 * when the order has one.
341 *
342 * @param WC_Abstract_Order $order Order to label.
343 * @return string
344 */
345 function openstation_my_wordpress_woo_order_title( $order ) {
346 $name = method_exists( $order, 'get_formatted_billing_full_name' )
347 ? trim( $order->get_formatted_billing_full_name() )
348 : '';
349
350 if ( '' !== $name ) {
351 return sprintf(
352 /* translators: 1: order number, 2: customer name. */
353 __( 'Order #%1$s · %2$s', 'desktop-mode' ),
354 $order->get_order_number(),
355 $name
356 );
357 }
358
359 return sprintf(
360 /* translators: %s: order number. */
361 __( 'Order #%s', 'desktop-mode' ),
362 $order->get_order_number()
363 );
364 }
365
366 /**
367 * Announce an identity for WooCommerce's own screens, and hang the
368 * shop's links off the identities the built-in detection already
369 * produces.
370 *
371 * @param array|null $identity Identity so far.
372 * @param WP_Screen|null $screen Current screen, when available.
373 * @return array|null
374 */
375 function openstation_my_wordpress_woo_content_identity( $identity, $screen ) {
376 unset( $screen );
377 if ( ! openstation_my_wordpress_woo_active() ) {
378 return $identity;
379 }
380
381 // 1. Order edit. Under HPOS there is no identity yet at all —
382 // `post.php` never runs — so this is the only place it can come
383 // from. Under legacy storage there IS one (the generic post
384 // branch), and it arrives with no links: an order's content is
385 // empty, so the hyperlink/media/term extractor finds nothing.
386 $order = openstation_my_wordpress_woo_current_order();
387 if ( $order && openstation_my_wordpress_woo_can_read_orders() ) {
388 $identity = array(
389 'type' => 'shop_order',
390 'id' => (int) $order->get_id(),
391 'label' => openstation_my_wordpress_woo_order_title( $order ),
392 );
393
394 $links = openstation_my_wordpress_woo_order_refs( $order );
395 if ( ! empty( $links ) ) {
396 $identity['links'] = $links;
397 }
398
399 return $identity;
400 }
401
402 // 2. The product-reviews screen, filtered to one product —
403 // `edit.php?post_type=product&page=product-reviews&product_id=N`,
404 // the target the Related menu's "Reviews" item opens.
405 //
406 // Without this the item opened a window that drew no tie to the
407 // product it came from, while every other item in the same menu
408 // did. The reason is structural rather than a bug in the menu:
409 // a tie needs BOTH windows to have an identity, and WooCommerce
410 // moved reviews off `edit-comments.php` onto its own admin page,
411 // which the built-in detection has no reason to know about. (The
412 // older `edit-comments.php?p=N` route is already covered by core
413 // detection, which is why a post's comments window ties.)
414 //
415 // Rooted at the product, exactly like the built-in comments
416 // identity is rooted at its post: reviews belong to the thing
417 // they review.
418 $reviews_identity = openstation_my_wordpress_woo_reviews_identity();
419 if ( $reviews_identity ) {
420 return $reviews_identity;
421 }
422
423 // 3. Coupon edit — the built-in post branch gives the identity;
424 // the restrictions are what make it interesting.
425 if ( is_array( $identity ) && 'shop_coupon' === ( $identity['type'] ?? '' ) ) {
426 $coupon = new WC_Coupon( (int) $identity['id'] );
427 if ( $coupon->get_id() ) {
428 $links = openstation_my_wordpress_woo_coupon_refs( $coupon );
429 if ( ! empty( $links ) ) {
430 $identity['links'] = array_merge(
431 (array) ( $identity['links'] ?? array() ),
432 $links
433 );
434 }
435 }
436 }
437
438 return $identity;
439 }
440
441 /*
442 -------------------------------------------------------------------
443 * Related entities — the title bar's "Related" menu
444 * ----------------------------------------------------------------
445 */
446
447 /**
448 * A related-entity item, with the fields the sanitizer requires.
449 *
450 * @param string $id Unique id in the list.
451 * @param string $group Section key.
452 * @param string $group_label Section header.
453 * @param string $label Item label.
454 * @param string $icon Dashicon class.
455 * @param string $url Admin URL to open.
456 * @param int $count Optional count badge; 0 omits it.
457 * @return array
458 */
459 function openstation_my_wordpress_woo_related_item( $id, $group, $group_label, $label, $icon, $url, $count = 0 ) {
460 $item = array(
461 'id' => $id,
462 'group' => $group,
463 'groupLabel' => $group_label,
464 'label' => $label,
465 'icon' => $icon,
466 'url' => $url,
467 );
468 if ( $count > 0 ) {
469 $item['count'] = (int) $count;
470 }
471
472 return $item;
473 }
474
475 /**
476 * Related items for an order: the customer, every product on it, and
477 * every coupon it used.
478 *
479 * @param WC_Abstract_Order $order Order.
480 * @return array[]
481 */
482 function openstation_my_wordpress_woo_order_related( $order ) {
483 $related = array();
484
485 $customer_id = method_exists( $order, 'get_customer_id' ) ? (int) $order->get_customer_id() : 0;
486 if ( $customer_id > 0 ) {
487 $user = get_userdata( $customer_id );
488 if ( $user instanceof WP_User ) {
489 $label = $user->display_name ? $user->display_name : $user->user_login;
490
491 if ( current_user_can( 'edit_user', $customer_id ) ) {
492 // The person, as a customer. From an order, "customer"
493 // means *this is who bought it* — not *change their
494 // role* — so this opens the Customer window rather
495 // than the profile editor.
496 //
497 // The Related menu can only express a destination as
498 // a URL, and the only URL WordPress has for a person
499 // is their profile editor. The marker is what lets a
500 // specific view claim that URL: the shell's built-in
501 // profile remap stands down on any person-URL carrying
502 // it, so the claim doesn't depend on winning a
503 // registration-order race.
504 $related[] = openstation_my_wordpress_woo_related_item(
505 'wc-customer-' . $customer_id,
506 'wc-customer',
507 __( 'Customer', 'desktop-mode' ),
508 $label,
509 'dashicons-businessperson',
510 add_query_arg(
511 OPENSTATION_PERSON_VIEW_PARAM,
512 'wc-customer',
513 (string) get_edit_user_link( $customer_id )
514 )
515 );
516
517 // The profile editor is still one item away, unmarked
518 // — it is a real destination, just not the one
519 // "customer" means from an order.
520 $related[] = openstation_my_wordpress_woo_related_item(
521 'wc-customer-profile-' . $customer_id,
522 'wc-customer',
523 __( 'Customer', 'desktop-mode' ),
524 __( 'Edit profile', 'desktop-mode' ),
525 'dashicons-admin-users',
526 (string) get_edit_user_link( $customer_id )
527 );
528 }
529
530 // Their other orders. The count comes off the cached
531 // aggregate the Customers section already builds, so this
532 // is free — and an item that opens a list the merchant
533 // then has to filter by hand is not worth the click.
534 $map = function_exists( 'openstation_my_wordpress_woo_customer_spend_map' )
535 ? openstation_my_wordpress_woo_customer_spend_map()
536 : array();
537 $orders = (int) ( $map[ $customer_id ]['orders'] ?? 0 );
538 if ( $orders > 1 && function_exists( 'openstation_my_wordpress_woo_customer_orders_url' ) ) {
539 $related[] = openstation_my_wordpress_woo_related_item(
540 'wc-customer-orders-' . $customer_id,
541 'wc-customer',
542 __( 'Customer', 'desktop-mode' ),
543 __( 'All orders by this customer', 'desktop-mode' ),
544 'dashicons-cart',
545 openstation_my_wordpress_woo_customer_orders_url( $customer_id ),
546 $orders
547 );
548 }
549 }
550 }
551
552 $items = 0;
553 foreach ( $order->get_items() as $item ) {
554 if ( $items >= OPENSTATION_WOO_RELATION_ITEM_CAP ) {
555 break;
556 }
557 $product_id = method_exists( $item, 'get_product_id' ) ? (int) $item->get_product_id() : 0;
558 if ( $product_id <= 0 || ! get_post( $product_id ) ) {
559 // A line item whose product has since been deleted has no
560 // screen to open. It still reads correctly as text on the
561 // order itself; it just isn't navigation.
562 continue;
563 }
564 if ( ! current_user_can( 'edit_post', $product_id ) ) {
565 continue;
566 }
567 $related[] = openstation_my_wordpress_woo_related_item(
568 'wc-product-' . $product_id,
569 'wc-products',
570 __( 'Products', 'desktop-mode' ),
571 $item->get_name(),
572 'dashicons-products',
573 (string) get_edit_post_link( $product_id, 'raw' ),
574 (int) $item->get_quantity()
575 );
576 ++$items;
577 }
578
579 $coupons = 0;
580 foreach ( $order->get_items( 'coupon' ) as $line ) {
581 if ( $coupons >= OPENSTATION_WOO_RELATION_ITEM_CAP ) {
582 break;
583 }
584 $coupon = new WC_Coupon( $line->get_code() );
585 if ( ! $coupon->get_id() || ! current_user_can( 'edit_post', $coupon->get_id() ) ) {
586 continue;
587 }
588 $related[] = openstation_my_wordpress_woo_related_item(
589 'wc-coupon-' . $coupon->get_id(),
590 'wc-coupons',
591 __( 'Coupons', 'desktop-mode' ),
592 $coupon->get_code(),
593 'dashicons-tickets-alt',
594 (string) get_edit_post_link( $coupon->get_id(), 'raw' )
595 );
596 ++$coupons;
597 }
598
599 return $related;
600 }
601
602 /**
603 * Order ids containing a given product, newest first.
604 *
605 * Read from the order-items tables rather than through
606 * `wc_get_orders()`, because there is no "orders containing product X"
607 * query in the WooCommerce API and walking orders to find one would
608 * mean loading every order on the store. Those two tables are the
609 * right index and they are populated under BOTH storages — High
610 * Performance Order Storage moves the order rows, not the line items.
611 *
612 * Matches `_variation_id` as well as `_product_id`: a variation is
613 * sold as its own line, and a merchant asking "who bought this
614 * product" means the variable product too.
615 *
616 * @param int $product_id Product id.
617 * @param int $limit How many orders.
618 * @return int[] Order ids.
619 */
620 function openstation_my_wordpress_woo_orders_with_product( $product_id, $limit = 10 ) {
621 global $wpdb;
622
623 $product_id = (int) $product_id;
624 $limit = max( 1, (int) $limit );
625 // The order-items tables are WooCommerce's, not core's. Without
626 // the plugin they don't exist, and the query would print a
627 // "table doesn't exist" notice into whatever page called it.
628 if ( $product_id <= 0 || ! openstation_my_wordpress_woo_active() ) {
629 return array();
630 }
631
632 $items = $wpdb->prefix . 'woocommerce_order_items';
633 $itemmeta = $wpdb->prefix . 'woocommerce_order_itemmeta';
634
635 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are structural; every value is prepared.
636 $sql = $wpdb->prepare(
637 "SELECT DISTINCT oi.order_id
638 FROM {$items} oi
639 INNER JOIN {$itemmeta} oim ON oim.order_item_id = oi.order_item_id
640 WHERE oi.order_item_type = 'line_item'
641 AND oim.meta_key IN ( '_product_id', '_variation_id' )
642 AND oim.meta_value = %d
643 ORDER BY oi.order_id DESC
644 LIMIT %d",
645 $product_id,
646 $limit
647 );
648
649 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- no core API answers "orders containing product X"; $sql came out of prepare() above, and the result feeds one menu render.
650 $ids = $wpdb->get_col( $sql );
651
652 return array_map( 'intval', (array) $ids );
653 }
654
655 /**
656 * Coupons restricted to a given product (or to one of its categories).
657 *
658 * WooCommerce stores both restrictions as comma-separated id strings
659 * in postmeta, which no meta query can search reliably — `LIKE
660 * '%12%'` matches 112 and 121. So the rows are read and split in PHP.
661 * Bounded: a store with more coupons than this has a coupon strategy,
662 * not a coupon, and the menu is not the place to enumerate it.
663 *
664 * @param int $product_id Product id.
665 * @param int $limit How many coupons.
666 * @return int[] Coupon post ids.
667 */
668 function openstation_my_wordpress_woo_coupons_for_product( $product_id, $limit = 8 ) {
669 global $wpdb;
670
671 $product_id = (int) $product_id;
672 if ( $product_id <= 0 ) {
673 return array();
674 }
675
676 $category_ids = wp_get_post_terms( $product_id, 'product_cat', array( 'fields' => 'ids' ) );
677 $category_ids = is_wp_error( $category_ids ) ? array() : array_map( 'intval', $category_ids );
678
679 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are structural.
680 $sql = "SELECT pm.post_id, pm.meta_key, pm.meta_value
681 FROM {$wpdb->postmeta} pm
682 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
683 WHERE p.post_type = 'shop_coupon'
684 AND p.post_status = 'publish'
685 AND pm.meta_key IN ( 'product_ids', 'product_categories' )
686 LIMIT 400";
687
688 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- comma-joined id strings can't be searched with a meta query; bounded scan feeding one menu render.
689 $rows = $wpdb->get_results( $sql );
690
691 $matched = array();
692 foreach ( (array) $rows as $row ) {
693 if ( count( $matched ) >= $limit ) {
694 break;
695 }
696 $values = array_filter( array_map( 'intval', explode( ',', (string) $row->meta_value ) ) );
697 if ( empty( $values ) ) {
698 continue;
699 }
700 $hit = 'product_ids' === $row->meta_key
701 ? in_array( $product_id, $values, true )
702 : ( ! empty( array_intersect( $category_ids, $values ) ) );
703 if ( $hit ) {
704 $matched[ (int) $row->post_id ] = true;
705 }
706 }
707
708 return array_map( 'intval', array_keys( $matched ) );
709 }
710
711 /**
712 * Related items for a product: everything the catalogue screen knows
713 * about it and can't take you to.
714 *
715 * The built-in related pass covers `post` and `page` only, so a
716 * product gets none of this for free — its taxonomies are exactly as
717 * navigable as its order history, which is to say not at all.
718 *
719 * Budgets are per group and add up deliberately. The engine hard-caps
720 * the whole `related` list at 64, and an unbudgeted group would push
721 * the trailing ones silently over — losing the orders because a
722 * product happened to carry thirty tags. Worst case here is
723 * 10 + 10 + 1 + 1 + 10 + 8 + 8 = 48.
724 *
725 * @param int $product_id Product id.
726 * @return array[]
727 */
728 function openstation_my_wordpress_woo_product_related( $product_id ) {
729 $related = array();
730 $product = wc_get_product( $product_id );
731 if ( ! $product ) {
732 return $related;
733 }
734
735 foreach ( array( 'product_cat', 'product_tag' ) as $taxonomy ) {
736 $tax = get_taxonomy( $taxonomy );
737 if ( ! $tax ) {
738 continue;
739 }
740 $terms = get_the_terms( $product_id, $taxonomy );
741 if ( ! is_array( $terms ) ) {
742 continue;
743 }
744 foreach ( array_slice( $terms, 0, 10 ) as $term ) {
745 $related[] = openstation_my_wordpress_woo_related_item(
746 'wc-term-' . $taxonomy . '-' . (int) $term->term_id,
747 'terms/' . $taxonomy,
748 (string) $tax->labels->name,
749 $term->name,
750 'product_cat' === $taxonomy ? 'dashicons-category' : 'dashicons-tag',
751 admin_url(
752 'term.php?taxonomy=' . rawurlencode( $taxonomy ) . '&tag_ID=' . (int) $term->term_id . '&post_type=product'
753 )
754 );
755 }
756 }
757
758 // Reviews. WooCommerce files them as comments of type `review`,
759 // and its own Reviews screen is the comment list with that filter
760 // pre-applied — which is exactly the URL worth linking.
761 $reviews = (int) $product->get_review_count();
762 if ( $reviews > 0 && current_user_can( 'moderate_comments' ) ) {
763 $related[] = openstation_my_wordpress_woo_related_item(
764 'wc-reviews-' . $product_id,
765 'wc-reviews',
766 __( 'Reviews', 'desktop-mode' ),
767 __( 'Reviews', 'desktop-mode' ),
768 'dashicons-star-filled',
769 admin_url( 'edit.php?post_type=product&page=product-reviews&product_id=' . (int) $product_id ),
770 $reviews
771 );
772 }
773
774 // Variations edit through their parent's screen, but a variable
775 // product's children are the thing a merchant actually adjusts —
776 // surface the parent screen's variations tab as one jump.
777 if ( $product->is_type( 'variable' ) ) {
778 $children = count( $product->get_children() );
779 if ( $children > 0 ) {
780 $related[] = openstation_my_wordpress_woo_related_item(
781 'wc-variations-' . $product_id,
782 'wc-products',
783 __( 'Product', 'desktop-mode' ),
784 __( 'Variations', 'desktop-mode' ),
785 'dashicons-networking',
786 (string) get_edit_post_link( $product_id, 'raw' ) . '#variable_product_options',
787 $children
788 );
789 }
790 }
791
792 // The other half of the story: who bought it. An order names its
793 // products, so the order → product jump has always worked; the
794 // reverse is the one a merchant actually asks for ("is this
795 // selling? who to?") and the catalogue screen has no answer at
796 // all.
797 //
798 // Gated on order access rather than on `edit_post`: this is order
799 // data reached from a product screen, and a shop editor who may
800 // not read orders must not read them sideways.
801 if ( openstation_my_wordpress_woo_can_read_orders() ) {
802 $customers = array();
803 $listed = 0;
804 foreach ( openstation_my_wordpress_woo_orders_with_product( $product_id, OPENSTATION_WOO_RELATION_ORDER_CANDIDATES ) as $order_id ) {
805 if ( $listed >= OPENSTATION_WOO_RELATION_ORDER_CAP ) {
806 break;
807 }
808 $order = wc_get_order( $order_id );
809 if ( ! openstation_my_wordpress_woo_is_purchase( $order ) ) {
810 continue;
811 }
812 ++$listed;
813
814 $name = method_exists( $order, 'get_formatted_billing_full_name' )
815 ? trim( $order->get_formatted_billing_full_name() )
816 : '';
817 $total = openstation_my_wordpress_woo_price(
818 $order->get_total(),
819 $order->get_currency()
820 );
821
822 $related[] = openstation_my_wordpress_woo_related_item(
823 'wc-order-' . $order_id,
824 'wc-orders',
825 __( 'Orders', 'desktop-mode' ),
826 '' !== $name
827 ? sprintf(
828 /* translators: 1: order number, 2: customer name, 3: order total. */
829 __( '#%1$s · %2$s · %3$s', 'desktop-mode' ),
830 $order->get_order_number(),
831 $name,
832 $total
833 )
834 : sprintf(
835 /* translators: 1: order number, 2: order total. */
836 __( '#%1$s · %2$s', 'desktop-mode' ),
837 $order->get_order_number(),
838 $total
839 ),
840 'dashicons-cart',
841 method_exists( $order, 'get_edit_order_url' )
842 ? (string) $order->get_edit_order_url()
843 : ''
844 );
845
846 // Harvested from the same orders rather than queried
847 // again — the buyers of a product ARE the customers on
848 // its orders, and a second query would only say so more
849 // slowly.
850 $customer_id = method_exists( $order, 'get_customer_id' )
851 ? (int) $order->get_customer_id()
852 : 0;
853 if ( $customer_id > 0 && ! isset( $customers[ $customer_id ] ) ) {
854 $customers[ $customer_id ] = true;
855 }
856 }
857
858 $shown = 0;
859 foreach ( array_keys( $customers ) as $customer_id ) {
860 if ( $shown >= 8 ) {
861 break;
862 }
863 $user = get_userdata( (int) $customer_id );
864 if ( ! $user instanceof WP_User || ! current_user_can( 'edit_user', $user->ID ) ) {
865 continue;
866 }
867 $related[] = openstation_my_wordpress_woo_related_item(
868 'wc-buyer-' . (int) $customer_id,
869 'wc-customer',
870 __( 'Customers', 'desktop-mode' ),
871 $user->display_name ? $user->display_name : $user->user_login,
872 'dashicons-businessperson',
873 // The Customer window, not the profile editor — from
874 // a product or a coupon, a person is a buyer.
875 add_query_arg(
876 OPENSTATION_PERSON_VIEW_PARAM,
877 'wc-customer',
878 (string) get_edit_user_link( $user->ID )
879 )
880 );
881 ++$shown;
882 }
883 }
884
885 // Coupons that discount it — WooCommerce shows the relationship
886 // only from the coupon's side, as a token field, so from the
887 // product there is currently no way to learn it is on offer.
888 foreach ( openstation_my_wordpress_woo_coupons_for_product( $product_id, 8 ) as $coupon_id ) {
889 if ( ! current_user_can( 'edit_post', $coupon_id ) ) {
890 continue;
891 }
892 $coupon = new WC_Coupon( $coupon_id );
893 if ( ! $coupon->get_id() ) {
894 continue;
895 }
896 $related[] = openstation_my_wordpress_woo_related_item(
897 'wc-product-coupon-' . $coupon_id,
898 'wc-coupons',
899 __( 'Coupons', 'desktop-mode' ),
900 $coupon->get_code(),
901 'dashicons-tickets-alt',
902 (string) get_edit_post_link( $coupon_id, 'raw' )
903 );
904 }
905
906 return $related;
907 }
908
909 /**
910 * Order ids that used a given coupon code, newest first.
911 *
912 * Coupon usage is a line item like any other, so it lives in the same
913 * always-populated order-items tables. `order_item_name` holds the
914 * code, lowercased by WooCommerce on apply.
915 *
916 * @param string $code Coupon code.
917 * @param int $limit How many orders.
918 * @return int[] Order ids.
919 */
920 function openstation_my_wordpress_woo_orders_with_coupon( $code, $limit = 10 ) {
921 global $wpdb;
922
923 $code = strtolower( trim( (string) $code ) );
924 // Same table-ownership caveat as the product lookup above.
925 if ( '' === $code || ! openstation_my_wordpress_woo_active() ) {
926 return array();
927 }
928
929 $items = $wpdb->prefix . 'woocommerce_order_items';
930
931 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is structural; every value is prepared.
932 $sql = $wpdb->prepare(
933 "SELECT DISTINCT order_id
934 FROM {$items}
935 WHERE order_item_type = 'coupon' AND LOWER( order_item_name ) = %s
936 ORDER BY order_id DESC
937 LIMIT %d",
938 $code,
939 max( 1, (int) $limit )
940 );
941
942 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- no core API answers "orders that used coupon X"; $sql came out of prepare() above, and the result feeds one menu render.
943 $ids = $wpdb->get_col( $sql );
944
945 return array_map( 'intval', (array) $ids );
946 }
947
948 /**
949 * Related items for a coupon: what it is restricted to, and who
950 * actually used it.
951 *
952 * WooCommerce renders the restrictions as select2 token fields —
953 * readable only by clicking into each token, and not links to
954 * anywhere. The usage count it does show is a bare number with
955 * nothing behind it.
956 *
957 * Budgets: 20 + 20 + 10 + 8 = 58, inside the engine's 64-item cap.
958 *
959 * @param int $coupon_id Coupon id.
960 * @return array[]
961 */
962 function openstation_my_wordpress_woo_coupon_related( $coupon_id ) {
963 $related = array();
964 $coupon = new WC_Coupon( (int) $coupon_id );
965 if ( ! $coupon->get_id() ) {
966 return $related;
967 }
968
969 foreach ( array_slice( (array) $coupon->get_product_ids(), 0, OPENSTATION_WOO_RELATION_ITEM_CAP ) as $product_id ) {
970 $product = wc_get_product( (int) $product_id );
971 if ( ! $product || ! current_user_can( 'edit_post', (int) $product_id ) ) {
972 continue;
973 }
974 $related[] = openstation_my_wordpress_woo_related_item(
975 'wc-coupon-product-' . (int) $product_id,
976 'wc-products',
977 __( 'Applies to', 'desktop-mode' ),
978 $product->get_name(),
979 'dashicons-products',
980 (string) get_edit_post_link( (int) $product_id, 'raw' )
981 );
982 }
983
984 foreach ( array_slice( (array) $coupon->get_product_categories(), 0, OPENSTATION_WOO_RELATION_ITEM_CAP ) as $term_id ) {
985 $term = get_term( (int) $term_id, 'product_cat' );
986 if ( ! $term instanceof WP_Term ) {
987 continue;
988 }
989 $related[] = openstation_my_wordpress_woo_related_item(
990 'wc-coupon-cat-' . (int) $term_id,
991 'terms/product_cat',
992 __( 'Applies to', 'desktop-mode' ),
993 $term->name,
994 'dashicons-category',
995 admin_url( 'term.php?taxonomy=product_cat&tag_ID=' . (int) $term_id . '&post_type=product' )
996 );
997 }
998
999 // Who redeemed it. The coupon screen shows a usage count and
1000 // nothing behind it, so "did this campaign work, and for whom" is
1001 // a question you currently answer by exporting orders.
1002 if ( openstation_my_wordpress_woo_can_read_orders() ) {
1003 $customers = array();
1004 $listed = 0;
1005 foreach ( openstation_my_wordpress_woo_orders_with_coupon( $coupon->get_code(), OPENSTATION_WOO_RELATION_ORDER_CANDIDATES ) as $order_id ) {
1006 if ( $listed >= OPENSTATION_WOO_RELATION_ORDER_CAP ) {
1007 break;
1008 }
1009 $order = wc_get_order( $order_id );
1010 if ( ! openstation_my_wordpress_woo_is_purchase( $order ) ) {
1011 continue;
1012 }
1013 ++$listed;
1014
1015 $name = method_exists( $order, 'get_formatted_billing_full_name' )
1016 ? trim( $order->get_formatted_billing_full_name() )
1017 : '';
1018 $total = openstation_my_wordpress_woo_price(
1019 $order->get_total(),
1020 $order->get_currency()
1021 );
1022
1023 $related[] = openstation_my_wordpress_woo_related_item(
1024 'wc-coupon-order-' . $order_id,
1025 'wc-orders',
1026 __( 'Used on', 'desktop-mode' ),
1027 '' !== $name
1028 ? sprintf(
1029 /* translators: 1: order number, 2: customer name, 3: order total. */
1030 __( '#%1$s · %2$s · %3$s', 'desktop-mode' ),
1031 $order->get_order_number(),
1032 $name,
1033 $total
1034 )
1035 : sprintf(
1036 /* translators: 1: order number, 2: order total. */
1037 __( '#%1$s · %2$s', 'desktop-mode' ),
1038 $order->get_order_number(),
1039 $total
1040 ),
1041 'dashicons-cart',
1042 method_exists( $order, 'get_edit_order_url' )
1043 ? (string) $order->get_edit_order_url()
1044 : ''
1045 );
1046
1047 $customer_id = method_exists( $order, 'get_customer_id' )
1048 ? (int) $order->get_customer_id()
1049 : 0;
1050 if ( $customer_id > 0 ) {
1051 $customers[ $customer_id ] = true;
1052 }
1053 }
1054
1055 $shown = 0;
1056 foreach ( array_keys( $customers ) as $customer_id ) {
1057 if ( $shown >= 8 ) {
1058 break;
1059 }
1060 $user = get_userdata( (int) $customer_id );
1061 if ( ! $user instanceof WP_User || ! current_user_can( 'edit_user', $user->ID ) ) {
1062 continue;
1063 }
1064 $related[] = openstation_my_wordpress_woo_related_item(
1065 'wc-coupon-buyer-' . (int) $customer_id,
1066 'wc-customer',
1067 __( 'Customers', 'desktop-mode' ),
1068 $user->display_name ? $user->display_name : $user->user_login,
1069 'dashicons-businessperson',
1070 // The Customer window, not the profile editor — from
1071 // a product or a coupon, a person is a buyer.
1072 add_query_arg(
1073 OPENSTATION_PERSON_VIEW_PARAM,
1074 'wc-customer',
1075 (string) get_edit_user_link( $user->ID )
1076 )
1077 );
1078 ++$shown;
1079 }
1080 }
1081
1082 return $related;
1083 }
1084
1085 /**
1086 * Related items for a user identity: their orders, when they have any
1087 * and the viewer may see them.
1088 *
1089 * @param int $user_id User id.
1090 * @return array[]
1091 */
1092 function openstation_my_wordpress_woo_user_related( $user_id ) {
1093 if (
1094 ! function_exists( 'openstation_my_wordpress_woo_customer_spend_map' )
1095 || true !== openstation_my_wordpress_woo_customers_permission()
1096 ) {
1097 return array();
1098 }
1099
1100 $map = openstation_my_wordpress_woo_customer_spend_map();
1101 $stats = $map[ (int) $user_id ] ?? null;
1102 $orders = $stats ? (int) $stats['orders'] : 0;
1103 if ( $orders <= 0 ) {
1104 return array();
1105 }
1106
1107 return array(
1108 openstation_my_wordpress_woo_related_item(
1109 'wc-user-orders-' . (int) $user_id,
1110 'wc-orders',
1111 __( 'Store', 'desktop-mode' ),
1112 __( 'Orders', 'desktop-mode' ),
1113 'dashicons-cart',
1114 openstation_my_wordpress_woo_customer_orders_url( (int) $user_id ),
1115 $orders
1116 ),
1117 );
1118 }
1119
1120 /**
1121 * Hang WooCommerce's relations off whatever identity the screen
1122 * resolved to.
1123 *
1124 * @param array[] $related Related items so far.
1125 * @param array $identity The resolved content identity.
1126 * @param WP_Screen|null $screen Current screen, when available.
1127 * @return array[]
1128 */
1129 function openstation_my_wordpress_woo_related_entities( $related, $identity, $screen ) {
1130 unset( $screen );
1131 if ( ! openstation_my_wordpress_woo_active() || ! is_array( $identity ) ) {
1132 return $related;
1133 }
1134
1135 $type = (string) ( $identity['type'] ?? '' );
1136 $id = (int) ( $identity['id'] ?? 0 );
1137 if ( $id <= 0 ) {
1138 return $related;
1139 }
1140
1141 if ( 'shop_order' === $type && openstation_my_wordpress_woo_can_read_orders() ) {
1142 $order = wc_get_order( $id );
1143 if ( $order instanceof WC_Abstract_Order ) {
1144 $related = array_merge( (array) $related, openstation_my_wordpress_woo_order_related( $order ) );
1145 }
1146 } elseif ( 'product' === $type ) {
1147 $related = array_merge( (array) $related, openstation_my_wordpress_woo_product_related( $id ) );
1148 } elseif ( 'shop_coupon' === $type ) {
1149 $related = array_merge( (array) $related, openstation_my_wordpress_woo_coupon_related( $id ) );
1150 } elseif ( 'user' === $type ) {
1151 $related = array_merge( (array) $related, openstation_my_wordpress_woo_user_related( $id ) );
1152 }
1153
1154 return $related;
1155 }
1156
1157 /**
1158 * Name the order-edit window after the order it shows.
1159 *
1160 * @return void
1161 */
1162 function openstation_my_wordpress_woo_order_window_title() {
1163 if ( ! openstation_my_wordpress_woo_active() || ! openstation_my_wordpress_woo_can_read_orders() ) {
1164 return;
1165 }
1166
1167 $order = openstation_my_wordpress_woo_current_order();
1168 if ( ! $order ) {
1169 return;
1170 }
1171
1172 // `JSON_HEX_TAG` neutralises a `</script>` smuggled in through the billing name.
1173 $title_json = wp_json_encode(
1174 openstation_my_wordpress_woo_order_title( $order ),
1175 JSON_HEX_TAG | JSON_UNESCAPED_SLASHES
1176 );
1177 if ( false === $title_json ) {
1178 return;
1179 }
1180 wp_print_inline_script_tag(
1181 'window.parent.postMessage({type:"os-title-change",title:' . $title_json . '},window.location.origin);'
1182 );
1183 }
1184
1185 /**
1186 * Boot the relations wiring.
1187 *
1188 * Priority 20 on the identity filter so a site that overrides the
1189 * order identity for its own reasons still wins.
1190 *
1191 * @return void
1192 */
1193 function openstation_my_wordpress_woo_relations_boot() {
1194 add_filter( 'openstation_window_content_identity', 'openstation_my_wordpress_woo_content_identity', 20, 2 );
1195 add_filter( 'openstation_window_related_entities', 'openstation_my_wordpress_woo_related_entities', 20, 3 );
1196 add_action( 'openstation_chromeless_after', 'openstation_my_wordpress_woo_order_window_title' );
1197 }
1198 openstation_my_wordpress_woo_relations_boot();
1199