diff --git a/CHANGELOG.md b/CHANGELOG.md index f62927d..bd7b5b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to the InstaWP Integration plugin will be documented in this The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.12] - 2026-05-08 + +### Added +- **Show/Hide password toggle on the Sites list.** Click to reveal a site's WordPress password inline; click again to hide it. + +### Changed +- **Full WooCommerce HPOS (High-Performance Order Storage) support.** Sites created from orders now show up correctly everywhere — the customer's My Account dashboard, the order details page, and the admin "Sites created" statistic — regardless of which order storage WooCommerce is configured to use. Orders from older plugin versions are upgraded automatically on first read. + +### Fixed +- **Clearer "subdomain already taken" error.** Customers attempting to use a subdomain that's already in use now see *"The subdomain "X" is already taken. Please choose a different one."* — on the product checkout, the thank-you page, the deferred-onboarding form, and the order-confirmation email. Replaces the previous raw technical error text. +- **Failed site creations now show the real reason to the customer.** On the thank-you page, order details, and order-confirmation email, the customer now sees the actual cause of the failure (e.g. subdomain taken) instead of a generic "Sorry, please contact support" message. +- **Admin Sites list shows the failure reason at a glance.** Failed sites now display the real cause directly under the "Failed" status badge — no need to click into the order to find out what went wrong. +- **No more phantom rows in the admin Sites list.** Failed or trashed sites that have no usable URL are hidden from every tab, since they carry no actionable information. +- **Old orphaned sites can now be deleted.** Sites left over in order meta from older plugin versions (before the dedicated sites table existed) used to silently reappear after deletion. They can now be deleted properly and stay deleted. +- **Every failed order now keeps its own record.** Previously, due to a database quirk, only the first failed site creation per cycle was recorded — every subsequent failed order silently lost its row, and the failed card didn't render on the customer's view. Each failed order now keeps its own record and renders correctly. + ## [0.0.11] - 2026-04-21 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 1582f40..bc922c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,19 @@ iwp-wp-integration/ Handles all communication with InstaWP's REST API. +#### `IWP_PLUGIN_APP_URL` — no trailing slash + +Defined in `iwp-wp-integration.php`. This constant is the base host for every +URL the plugin builds — API calls, magic-login URLs, dashboard links, values +passed to localized JS. + +**Rule:** store as scheme + host only, no path and no trailing slash +(`'https://app.instawp.io'`, never `'https://app.instawp.io/'`). All +consumer code owns the leading slash on its side (`. '/api/v2/...'`, +`. '/wordpress-auto-login?...'`). Concatenating a trailing-slash base with a +leading-slash path produces doubled slashes in the resulting URL, which some +reverse-proxy / router stacks no longer silently normalize. + #### Key Features - **Authentication**: Bearer token with API key - **Endpoints**: Complete API coverage (v1 and v2) @@ -677,6 +690,28 @@ if (!empty($s_hash)) { // "Magic Login" button replaces "Admin Login" when s_hash available ``` +### HPOS Compatibility (`class-iwp-woo-hpos.php`) + +`IWP_Woo_HPOS` is the abstraction layer for WooCommerce's High-Performance Order Storage. Always go through it for order/order-meta reads and writes — never query `wp_posts`/`wp_postmeta` directly when looking for `shop_order` rows or their meta. Under authoritative HPOS those tables don't carry the data, so direct queries silently return nothing. + +#### Picking the right read helper + +| Scenario | Use | Why | +|----------|-----|-----| +| You have an order ID and want one meta value | `IWP_Woo_HPOS::get_order_meta($order_or_id, $key)` | Reads from active store, falls back to legacy postmeta and forward-migrates on hit. | +| You want orders matching first-class args (customer/status/date) and meta is incidental | `IWP_Woo_HPOS::get_orders($args_with_meta_query)` | Wraps `wc_get_orders()`. Strips `meta_query` and re-evaluates in PHP via `get_order_meta()` — handles legacy postmeta. **Slow on site-wide queries** (loads all matching orders into objects before filtering); the docstring at the top of the method warns to bound it with first-class args or a `limit`. | +| You want every order that has any of N meta keys, and meta IS the primary selector | `IWP_Woo_HPOS::get_orders_with_meta(array $meta_keys)` | Single raw SQL query (with UNION on the legacy postmeta path under HPOS). Returns raw `(order_id, meta_key, meta_value)` rows; caller decides whether to `maybe_unserialize` and whether to hydrate orders. **Use this on admin list pages** and any site-wide scan. | + +#### Read-helper performance trade-off + +`get_orders()` is ergonomic but does PHP-side meta filtering — fine when the result set is bounded by other args (e.g. one customer's orders) but quadratic-ish when used on a whole-store scan. `get_orders_with_meta()` exists exactly for the whole-store-scan case: one indexed query, returns only matching rows, no per-non-match hydration cost. + +Concrete example: the admin Sites list at `wp-admin/admin.php?page=instawp-sites` has to enumerate every order with `_iwp_sites_created` or `_iwp_created_sites` meta. Using `get_orders(['limit' => -1, 'meta_query' => ...])` on a 100k-order store would hydrate all 100k orders into PHP and then filter — likely OOM at default admin memory limits. `get_orders_with_meta()` returns the matching ~50 rows in a single millisecond-scale query. + +#### Writes + +Use `IWP_Woo_HPOS::update_order_meta($order_or_id, $key, $value)` and `IWP_Woo_HPOS::delete_order_meta($order_or_id, $key)`. They route through the active store and run the standard WC order-update hooks. Direct `update_post_meta()`/`delete_post_meta()` writes are an anti-pattern — under HPOS they end up only in `wp_postmeta` and become invisible to the rest of the plugin (and require the `get_order_meta()` legacy-fallback path to repair on next read). + ## Admin Interface ### Simplified Admin System diff --git a/README.md b/README.md index 6ad2ffa..c1297a1 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,25 @@ This plugin is licensed under the GPL v2 or later. ## Changelog +### Version 0.0.12 +- **NEW**: Show/Hide password toggle on the Sites list — click to reveal a site's WordPress password inline +- **CHANGED**: Full WooCommerce HPOS support — sites created from orders show up correctly everywhere (My Account dashboard, order details, admin "Sites created" stat); older orders are upgraded automatically on first read +- **FIXED**: Clearer "subdomain already taken" error — customers see *"The subdomain X is already taken. Please choose a different one."* on checkout, thank-you page, deferred-onboarding form, and order email +- **FIXED**: Failed site creations now show the real reason to the customer on the thank-you page, order details, and order email — not just a generic "contact support" message +- **FIXED**: Admin Sites list shows the failure reason under the "Failed" badge, so admins can see at a glance why each one failed +- **FIXED**: No more phantom rows in the admin Sites list — failed/trashed sites with no URL are hidden from every tab +- **FIXED**: Old orphaned sites (left over from older plugin versions) can now be deleted from the admin Sites list — previously they would reappear after deletion +- **FIXED**: Every failed order now keeps its own record — previously a database quirk caused only the first failed site per cycle to be recorded + +### Version 0.0.11 +- **NEW: Trash filter on the Sites list page** — deleting a site now moves it to a new **Trash** status instead of removing the row +- **NEW: Trash view** alongside All, Active, Creating, Failed, Expired; on Trash the URL is shown as plain text and row actions (Visit Site, Magic Login, Delete, Send Credentials, Open in InstaWP) are hidden +- **NEW: "Show Site Credentials on Dashboard" option** (off by default) — when enabled, each site card on My Account shows the WordPress admin username and password +- **CHANGED: Default "All" view now excludes trashed sites** (standard WordPress convention); deleting a site no longer removes the local record — moved to **Trash** instead. Remote API delete still runs as before. +- **CHANGED: Trashed sites no longer appear in customer-facing views** (My Account dashboard, order detail page, order emails); the order detail "Sites" section is hidden entirely when every site for that order has been trashed +- **CHANGED: Snapshots cache duration increased from 15 minutes to 4 hours** — still overridable via **Refresh Snapshots** in Settings → InstaWP Data +- **FIXED: Trashed sites no longer get resurrected after a new order** — the pending-sites poller now skips trashed rows and treats a remote "site not found" response as proof the site is deleted + ### Version 0.0.10 - **NEW: Post-Purchase Onboarding** — defer site creation to after purchase, collect credentials via `[iwp_onboarding]` shortcode - **NEW: My Account deferred setup** — pending sites show on order view and dashboard banner diff --git a/assets/js/sites-list.js b/assets/js/sites-list.js new file mode 100644 index 0000000..604b6cd --- /dev/null +++ b/assets/js/sites-list.js @@ -0,0 +1,30 @@ +/** + * Sites list page UI for the InstaWP Integration admin. + * + * Currently wires the Show/Hide toggle for the Password column rendered by + * IWP_Sites_List_Table::column_password(). + * + * @package IWP + */ + +(function ($) { + 'use strict'; + + var labels = (window.iwpSitesList && window.iwpSitesList.labels) || { + show: 'Show', + hide: 'Hide' + }; + + $(document).on('click', '.iwp-show-password', function (e) { + e.preventDefault(); + var $btn = $(this); + var $revealed = $btn.next('.iwp-password-revealed'); + if ($btn.text() === labels.show) { + $revealed.text($btn.data('password')); + $btn.text(labels.hide); + } else { + $revealed.text(''); + $btn.text(labels.show); + } + }); +}(jQuery)); diff --git a/includes/admin/class-iwp-admin-simple.php b/includes/admin/class-iwp-admin-simple.php index d2fc0dd..c4ed2d3 100644 --- a/includes/admin/class-iwp-admin-simple.php +++ b/includes/admin/class-iwp-admin-simple.php @@ -259,30 +259,35 @@ private function get_sites_from_orders() { if (!function_exists('wc_get_orders')) { return array(); } - - $orders = wc_get_orders(array( - 'limit' => 50, - 'status' => array('completed', 'processing'), + + // Route through the HPOS/CPT compat wrapper so meta_query is evaluated + // correctly on both data stores and orders whose meta still lives in + // wp_postmeta (pre-HPOS-fix plugin builds) are included. + $orders = IWP_Woo_HPOS::get_orders(array( + 'limit' => 50, + 'status' => array('completed', 'processing'), 'meta_query' => array( array( - 'key' => '_iwp_created_sites', - 'compare' => 'EXISTS' - ) - ) + 'key' => '_iwp_created_sites', + 'compare' => 'EXISTS', + ), + ), )); - + $sites = array(); foreach ($orders as $order) { - $order_sites = $order->get_meta('_iwp_created_sites'); + // HPOS-safe read — handles wp_postmeta fallback + forward-migrate + // for values written by older plugin builds. + $order_sites = IWP_Woo_HPOS::get_order_meta($order, '_iwp_created_sites'); if (is_array($order_sites)) { foreach ($order_sites as $site) { - $site['order_id'] = $order->get_id(); + $site['order_id'] = $order->get_id(); $site['order_date'] = $order->get_date_created()->format('Y-m-d H:i:s'); - $sites[] = $site; + $sites[] = $site; } } } - + return $sites; } @@ -592,6 +597,21 @@ public function enqueue_admin_scripts($hook) { wp_enqueue_script('jquery'); + // Sites list page UI (password show/hide toggle, etc.). + wp_enqueue_script( + 'iwp-sites-list', + IWP_PLUGIN_URL . 'assets/js/sites-list.js', + array('jquery'), + IWP_VERSION, + true + ); + wp_localize_script('iwp-sites-list', 'iwpSitesList', array( + 'labels' => array( + 'show' => __('Show', 'iwp-wp-integration'), + 'hide' => __('Hide', 'iwp-wp-integration'), + ), + )); + // Send Credentials button handler wp_add_inline_script('jquery', ' jQuery(document).ready(function($) { @@ -646,8 +666,17 @@ public function enqueue_admin_scripts($hook) { border-top: none; padding: 20px; } - .iwp-tab-content.active { - display: block; + .iwp-tab-content.active { + display: block; + } + .iwp-password-revealed { + display: block; + font-family: monospace; + word-break: break-all; + margin-top: 2px; + } + .iwp-password-revealed:empty { + display: none; } '); } @@ -986,7 +1015,11 @@ private function update_site_url_with_mapped_domain($order_id, $site_id, $domain } // Update created sites meta - $created_sites = $order->get_meta('_iwp_created_sites'); + // Read via the helper so pre-HPOS-fix orders whose _iwp_created_sites + // value still lives in wp_postmeta are picked up (and migrated + // forward). Write stays on the in-scope $order — one meta-only save, + // no order-lifecycle hooks. + $created_sites = IWP_Woo_HPOS::get_order_meta($order, '_iwp_created_sites'); if (is_array($created_sites)) { foreach ($created_sites as $index => $site) { if (isset($site['site_id']) && intval($site['site_id']) === $site_id) { @@ -996,7 +1029,7 @@ private function update_site_url_with_mapped_domain($order_id, $site_id, $domain } } $order->update_meta_data('_iwp_created_sites', $created_sites); - $order->save(); + $order->save_meta_data(); } // Update sites database if using IWP_Site_Manager diff --git a/includes/admin/class-iwp-admin.php b/includes/admin/class-iwp-admin.php index ff22a88..4bc9f7d 100644 --- a/includes/admin/class-iwp-admin.php +++ b/includes/admin/class-iwp-admin.php @@ -2194,22 +2194,26 @@ private function update_site_url_with_mapped_domain($order_id, $site_id, $domain error_log('IWP WooCommerce V2: Updating site URL to mapped domain: ' . $new_url); - // Update in _iwp_sites_created (order processor format) - $sites_created = get_post_meta($order_id, '_iwp_sites_created', true); + // Update in _iwp_sites_created (order processor format). + // Route through the HPOS-safe helpers so reads and writes target + // whichever table the active data store uses and so any legacy + // postmeta-only value is migrated forward on read. + $sites_created = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_sites_created'); if (is_array($sites_created)) { foreach ($sites_created as &$site_data) { if (isset($site_data['site_data']['site_id']) && $site_data['site_data']['site_id'] == $site_id) { $site_data['site_data']['site_url'] = $new_url; - $site_data['site_data']['wp_url'] = $new_url; + $site_data['site_data']['wp_url'] = $new_url; error_log('IWP WooCommerce V2: Updated site URL in _iwp_sites_created'); break; } } - update_post_meta($order_id, '_iwp_sites_created', $sites_created); + IWP_Woo_HPOS::update_order_meta($order_id, '_iwp_sites_created', $sites_created); } - // Update in _iwp_created_sites (site manager format) - $created_sites = get_post_meta($order_id, '_iwp_created_sites', true); + // Update in _iwp_created_sites (site manager format) — same HPOS-safe + // routing as above. + $created_sites = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_created_sites'); if (is_array($created_sites)) { foreach ($created_sites as &$site_info) { if (isset($site_info['site_id']) && $site_info['site_id'] == $site_id) { @@ -2218,7 +2222,7 @@ private function update_site_url_with_mapped_domain($order_id, $site_id, $domain break; } } - update_post_meta($order_id, '_iwp_created_sites', $created_sites); + IWP_Woo_HPOS::update_order_meta($order_id, '_iwp_created_sites', $created_sites); } error_log('IWP WooCommerce V2: Site URL update completed for domain: ' . $domain_name); @@ -2266,12 +2270,12 @@ public function ajax_delete_site() { )); } - // Remote API delete already succeeded above. Soft-delete the local - // row so it stays visible under the Trash filter. - if (IWP_Sites_Model::get_by_site_id($site_id)) { - IWP_Sites_Model::trash($site_id); - IWP_Logger::info('Marked site as trashed in database table', 'admin', array('site_id' => $site_id)); - } + // Remote API delete already succeeded above. Trash the local row + // (or insert a deletion marker for orphans that live only in order + // meta) so the site is hidden from the default list and visible + // under the Trash filter. + IWP_Sites_Model::trash($site_id); + IWP_Logger::info('Marked site as trashed in database table', 'admin', array('site_id' => $site_id)); IWP_Logger::info('Site deleted successfully', 'admin', array( 'site_id' => $site_id, diff --git a/includes/admin/class-iwp-sites-list-table.php b/includes/admin/class-iwp-sites-list-table.php index be2c7b9..36ddb2d 100644 --- a/includes/admin/class-iwp-sites-list-table.php +++ b/includes/admin/class-iwp-sites-list-table.php @@ -385,8 +385,11 @@ private function get_all_sites() { $db_sites = $this->get_sites_from_database(); $all_sites = array_merge($all_sites, $db_sites); - // Get sites from WooCommerce orders (legacy/backup) - $order_sites = $this->get_sites_from_orders(); + // Get sites from WooCommerce orders (legacy/backup). Pass $db_sites so + // any order-meta entry whose site_id is already represented in the DB + // (including trashed rows / deletion markers) is skipped at the source + // instead of relying on downstream dedupe. + $order_sites = $this->get_sites_from_orders($db_sites); $all_sites = array_merge($all_sites, $order_sites); // Get sites from shortcode usage (stored in options) @@ -461,10 +464,24 @@ private function get_sites_from_database() { // Check if credentials have been released $credentials_released = false; + $site_name = ''; + $user_name = ''; if (!empty($db_site->source_data)) { $source_data_parsed = json_decode($db_site->source_data, true); - if (is_array($source_data_parsed) && !empty($source_data_parsed['credentials_released'])) { - $credentials_released = true; + if (is_array($source_data_parsed)) { + if (!empty($source_data_parsed['credentials_released'])) { + $credentials_released = true; + } + + if (!empty($source_data_parsed['site_data']) && is_array($source_data_parsed['site_data'])) { + if (!empty($source_data_parsed['site_data']['site_name'])) { + $site_name = $source_data_parsed['site_data']['site_name']; + } + + if (!empty($source_data_parsed['site_data']['user_name'])) { + $user_name = $source_data_parsed['site_data']['user_name']; + } + } } } @@ -484,8 +501,8 @@ private function get_sites_from_database() { } $site = array( - 'site_url' => $db_site->site_url ?: '', - 'username' => $db_site->wp_username ?: '', + 'site_url' => $db_site->site_url ?: $site_name, + 'username' => $db_site->wp_username ?: $user_name, 'password' => $db_site->wp_password ?: '', 'user' => $this->get_user_display_name($order, $db_site->user_id), 'source' => $source_type['text'], @@ -502,7 +519,13 @@ private function get_sites_from_database() { 'is_expired' => $is_expired, 'hours_remaining' => $hours_remaining, 'expiry_hours' => $db_site->expiry_hours, - 'credentials_released' => $credentials_released + 'credentials_released' => $credentials_released, + // Pass api_response through so column_status can decode + // the failure message on demand for failed rows (via + // IWP_Site_Manager::resolve_failure_message). No + // precompute — the work only runs when a failed row + // actually renders. + 'api_response' => $db_site->api_response, ); $sites[] = $site; @@ -516,40 +539,45 @@ private function get_sites_from_database() { * * @return array */ - private function get_sites_from_orders() { - global $wpdb; - + private function get_sites_from_orders($db_sites = array()) { $sites = array(); - // Query all orders with InstaWP sites - $results = $wpdb->get_results(" - SELECT p.ID as order_id, pm.meta_value as sites_data - FROM {$wpdb->posts} p - JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id - WHERE p.post_type = 'shop_order' - AND pm.meta_key IN ('_iwp_sites_created', '_iwp_created_sites') - AND pm.meta_value IS NOT NULL - AND pm.meta_value != '' - "); + // Site_ids already represented by the DB-sourced list. Order-meta + // entries matching one of these are skipped: the DB row is the source + // of truth (including trashed rows / orphan deletion markers, which + // the downstream status filter then hides). + $db_site_ids = empty($db_sites) ? array(): array_column($db_sites, 'site_id'); + $has_site_ids = count($db_site_ids) > 0; + + // One HPOS-aware round trip — returns only orders that actually have + // one of the IWP meta keys (under either data store, with legacy + // postmeta fallback under HPOS). See + // IWP_Woo_HPOS::get_orders_with_meta() for the data-store branching. + $results = IWP_Woo_HPOS::get_orders_with_meta(array( + '_iwp_sites_created', + '_iwp_created_sites', + )); foreach ($results as $result) { - $order_id = $result->order_id; - $sites_data = maybe_unserialize($result->sites_data); - + $sites_data = maybe_unserialize($result->meta_value); if (!is_array($sites_data)) { continue; } - $order = wc_get_order($order_id); + $order = wc_get_order($result->order_id); if (!$order) { continue; } foreach ($sites_data as $site_data) { $site = $this->format_order_site($site_data, $order); - if ($site) { - $sites[] = $site; + if (!$site) { + continue; + } + if ( $has_site_ids && !empty($site['site_id']) && in_array($site['site_id'], $db_site_ids, true) ) { + continue; } + $sites[] = $site; } } @@ -908,9 +936,9 @@ public function column_password($item) { if (empty($item['password'])) { return '—'; } - + return sprintf( - '•••••••• ', + '•••••••• ', esc_attr($item['password']), __('Show', 'iwp-wp-integration') ); @@ -1006,6 +1034,7 @@ public function column_status($item) { $status = $item['status']; $class = ''; $text = ''; + $title = ''; switch ($status) { case 'completed': @@ -1036,6 +1065,13 @@ public function column_status($item) { case 'failed': $class = 'iwp-status-failed'; $text = __('Failed', 'iwp-wp-integration'); + // Append the humanized error so the Failed tab shows + // why each row failed (subdomain taken, etc.) instead + // of just a Failed badge. Decode on demand from the + // api_response carried in $item — no precompute. + $title = class_exists('IWP_Site_Manager') + ? IWP_Site_Manager::resolve_failure_message($item['api_response'] ?? '') + : ''; break; case IWP_Sites_Model::STATUS_TRASHED: $class = 'iwp-status-trashed'; @@ -1046,7 +1082,7 @@ public function column_status($item) { $text = __('Unknown', 'iwp-wp-integration'); } - return sprintf('%s', $class, $text); + return sprintf('%s', $class, esc_html($title), $text); } /** diff --git a/includes/core/class-iwp-api-client.php b/includes/core/class-iwp-api-client.php index 0030b50..57f4fc0 100644 --- a/includes/core/class-iwp-api-client.php +++ b/includes/core/class-iwp-api-client.php @@ -53,6 +53,35 @@ public function __construct() { $this->team_id = isset($options['selected_team_id']) ? intval($options['selected_team_id']) : null; } + /** + * Convert a raw API error message into customer-friendly text. + * + * Single source of truth for InstaWP API error rewrites. Currently + * handles the upstream "X site name is not available." response (the + * subdomain-already-taken case) and pass-through everything else + * verbatim. Add new rewrite rules here so every customer-facing + * surface (shortcode JSON responses, WooCommerce checkout WP_Errors, + * deferred-onboarding AJAX, subscription-switch order notes) gets + * the same humanized text. + * + * @param WP_Error|string $error WP_Error from this client, or a raw + * error message string. + * @return string Customer-friendly version of the message. + */ + public static function humanize_error($error) { + $message = is_wp_error($error) ? $error->get_error_message() : (string) $error; + + if (preg_match('/^(.+?) site name is not available\.?$/i', $message, $m)) { + return sprintf( + /* translators: %s is the site name the customer tried to use. */ + __('The site subdomain (%s) is already taken. Please choose a different one.', 'iwp-wp-integration'), + $m[1] + ); + } + + return $message; + } + /** * Set API key * @@ -149,19 +178,31 @@ private function make_request($endpoint, $args = array()) { IWP_Logger::debug('API response body received', 'api-client'); if ($response_code < 200 || $response_code >= 300) { - $error_message = sprintf( - __('API request failed with status code %d', 'iwp-woo-v2'), - $response_code - ); - - // Try to get error message from response body + // Prefer the upstream body message — it's already a human-readable + // explanation (e.g. "john-doe site name is not available."). Fall + // back to a generic "status code N" only when no message exists. $body_data = json_decode($response_body, true); - if (is_array($body_data) && isset($body_data['message'])) { - $error_message .= ': ' . sanitize_text_field($body_data['message']); + if (is_array($body_data) && !empty($body_data['message'])) { + $error_message = sanitize_text_field($body_data['message']); + } else { + $error_message = sprintf( + __('API request failed with status code %d', 'iwp-woo-v2'), + $response_code + ); } - - IWP_Logger::error('API request failed', 'api-client', array('error' => $error_message)); - return new WP_Error('api_request_failed', $error_message); + + // Log the RAW upstream message so support / debug context + // still captures exactly what the API said. + IWP_Logger::error('API request failed', 'api-client', array( + 'status_code' => $response_code, + 'error' => $error_message, + )); + + // Return a HUMANIZED WP_Error so every downstream caller — + // shortcode AJAX, WooCommerce checkout, deferred onboarding, + // subscription switch — surfaces customer-friendly text by + // default, without each having to know the rewrite rules. + return new WP_Error('api_request_failed', self::humanize_error($error_message)); } $data = json_decode($response_body, true); diff --git a/includes/core/class-iwp-database.php b/includes/core/class-iwp-database.php index 8df3b16..ce533ba 100644 --- a/includes/core/class-iwp-database.php +++ b/includes/core/class-iwp-database.php @@ -27,15 +27,19 @@ class IWP_Database { * @return bool */ public static function append_order_meta($order_id, $meta_key, $data) { - $existing_data = get_post_meta($order_id, $meta_key, true); - + // Route through IWP_Woo_HPOS so the read picks up legacy postmeta-only + // values (pre-HPOS-fix writes) and the append persists to whichever + // table the active data store owns (wp_wc_orders_meta under HPOS, + // wp_postmeta under CPT). + $existing_data = IWP_Woo_HPOS::get_order_meta($order_id, $meta_key); + if (!is_array($existing_data)) { $existing_data = array(); } - + $existing_data[] = $data; - - return update_post_meta($order_id, $meta_key, $existing_data); + + return IWP_Woo_HPOS::update_order_meta($order_id, $meta_key, $existing_data); } /** @@ -48,19 +52,17 @@ public static function append_order_meta($order_id, $meta_key, $data) { * @return bool */ public static function update_order_meta($order_id, $meta_key, $data, $merge_arrays = false) { - // Validate order exists - if (!wc_get_order($order_id)) { - return false; - } - if ($merge_arrays) { - $existing_data = get_post_meta($order_id, $meta_key, true); + // Helper handles the legacy fallback + active-store read in one call. + $existing_data = IWP_Woo_HPOS::get_order_meta($order_id, $meta_key); if (is_array($existing_data) && is_array($data)) { $data = array_merge($existing_data, $data); } } - return update_post_meta($order_id, $meta_key, $data); + // Helper handles order validation + HPOS-safe write; returns false if + // the order can't be resolved (same guard the old wc_get_order check gave). + return IWP_Woo_HPOS::update_order_meta($order_id, $meta_key, $data); } /** @@ -72,13 +74,15 @@ public static function update_order_meta($order_id, $meta_key, $data, $merge_arr public static function get_order_sites($order_id) { $sites = array(); - // Get sites from different meta keys (for compatibility) - $created_sites = get_post_meta($order_id, '_iwp_created_sites', true); + // Read from both meta keys via the HPOS-safe helper so reads land in + // the active data store's table and any legacy postmeta-only values + // are picked up (and migrated forward by the helper on first read). + $created_sites = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_created_sites'); if (is_array($created_sites)) { $sites = array_merge($sites, $created_sites); } - $sites_created = get_post_meta($order_id, '_iwp_sites_created', true); + $sites_created = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_sites_created'); if (is_array($sites_created)) { foreach ($sites_created as $site_data) { if (isset($site_data['site_data'])) { @@ -97,7 +101,9 @@ public static function get_order_sites($order_id) { * @return array */ public static function get_order_domains($order_id) { - $domains = get_post_meta($order_id, '_iwp_mapped_domains', true); + // HPOS-safe read — returns mapped domains from whichever table the + // active data store owns. + $domains = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_mapped_domains'); return is_array($domains) ? $domains : array(); } @@ -209,23 +215,27 @@ public static function get_users_for_dropdown($limit = 50) { */ public static function get_orders_with_sites($args = array()) { $default_args = array( - 'status' => array('completed', 'processing'), - 'limit' => 20, + 'status' => array('completed', 'processing'), + 'limit' => 20, 'meta_query' => array( 'relation' => 'OR', array( - 'key' => '_iwp_created_sites', - 'compare' => 'EXISTS' + 'key' => '_iwp_created_sites', + 'compare' => 'EXISTS', ), array( - 'key' => '_iwp_sites_created', - 'compare' => 'EXISTS' - ) - ) + 'key' => '_iwp_sites_created', + 'compare' => 'EXISTS', + ), + ), ); $args = wp_parse_args($args, $default_args); - return wc_get_orders($args); + + // Route through the HPOS/CPT compat wrapper so meta_query is evaluated + // correctly on both data stores and orders whose meta still lives in + // wp_postmeta (pre-HPOS-fix plugin builds) are included. + return IWP_Woo_HPOS::get_orders($args); } /** @@ -253,21 +263,38 @@ public static function get_site_creation_stats() { $stats = array( 'total_orders_with_sites' => 0, - 'total_sites_created' => 0, - 'sites_by_status' => array(), - 'sites_by_month' => array() + 'total_sites_created' => 0, + 'sites_by_status' => array(), + 'sites_by_month' => array(), ); - // Get orders with sites - $orders_query = " - SELECT COUNT(DISTINCT p.ID) as count - FROM {$wpdb->posts} p - INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id - WHERE p.post_type = 'shop_order' - AND (pm.meta_key = '_iwp_created_sites' OR pm.meta_key = '_iwp_sites_created') - "; - - $stats['total_orders_with_sites'] = $wpdb->get_var($orders_query); + // Under HPOS authoritative, orders are rows in wp_wc_orders (not + // wp_posts) and their meta lives in wp_wc_orders_meta (not wp_postmeta). + // The legacy JOIN on wp_posts/wp_postmeta returns 0 in that case, so + // branch on the active data store and target the right table via + // WooCommerce's OrderUtil helper. is_hpos_enabled() already gates on + // class_exists for OrderUtil, so no need to re-check. + if (IWP_Woo_HPOS::is_hpos_enabled()) { + $meta_table = \Automattic\WooCommerce\Utilities\OrderUtil::get_table_for_order_meta(); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $stats['total_orders_with_sites'] = (int) $wpdb->get_var( + "SELECT COUNT(DISTINCT order_id) + FROM {$meta_table} + WHERE meta_key IN ('_iwp_created_sites', '_iwp_sites_created')" + ); + } else { + // Legacy CPT path — orders are shop_order posts and meta is in postmeta. + $orders_query = " + SELECT COUNT(DISTINCT p.ID) as count + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id + WHERE p.post_type = 'shop_order' + AND (pm.meta_key = '_iwp_created_sites' OR pm.meta_key = '_iwp_sites_created') + "; + + $stats['total_orders_with_sites'] = (int) $wpdb->get_var($orders_query); + } return $stats; } diff --git a/includes/core/class-iwp-shortcode.php b/includes/core/class-iwp-shortcode.php index c250ea9..241edc6 100644 --- a/includes/core/class-iwp-shortcode.php +++ b/includes/core/class-iwp-shortcode.php @@ -204,7 +204,7 @@ public function handle_ajax_site_creation() { $result = $api_client->create_site_from_snapshot($snapshot_slug, $site_data); if (is_wp_error($result)) { - wp_send_json_error(array('message' => $result->get_error_message())); + wp_send_json_error(array('message' => IWP_API_Client::humanize_error($result))); } // Check if site was created successfully diff --git a/includes/core/class-iwp-site-manager.php b/includes/core/class-iwp-site-manager.php index 3f9943c..d61bee3 100644 --- a/includes/core/class-iwp-site-manager.php +++ b/includes/core/class-iwp-site-manager.php @@ -185,8 +185,17 @@ public function create_site_with_tracking($snapshot_slug, $site_data, $order_id, * @param array $site_info */ private function store_site_in_order($order_id, $site_info) { - // Get existing sites for this order - $existing_sites = get_post_meta($order_id, '_iwp_created_sites', true); + // Use the IWP_Woo_HPOS wrapper for consistency with the rest of the + // plugin's order-loading sites. It's a thin pass-through today but + // gives us one place to change order hydration behaviour later. + $order = IWP_Woo_HPOS::get_order($order_id); + if (!$order) { + return; + } + + // Read via the helper so pre-HPOS-fix values in wp_postmeta are picked + // up and migrated forward into the active data store on first read. + $existing_sites = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_created_sites'); if (!is_array($existing_sites)) { $existing_sites = array(); } @@ -194,11 +203,12 @@ private function store_site_in_order($order_id, $site_info) { // Add new site to the list $existing_sites[] = $site_info; - // Update order meta - update_post_meta($order_id, '_iwp_created_sites', $existing_sites); + // Write directly on the $order instance (meta-only save, no + // order-lifecycle hooks) — the order note below reuses the same object. + $order->update_meta_data('_iwp_created_sites', $existing_sites); + $order->save_meta_data(); // Add order note - $order = wc_get_order($order_id); if ($order) { if ($site_info['status'] === 'completed') { $note = sprintf( @@ -597,7 +607,8 @@ private function check_single_site_status($task_id, $site_data) { * @param array $site_info */ private function update_completed_site($order_id, $site_info) { - $existing_sites = get_post_meta($order_id, '_iwp_created_sites', true); + // Read via the HPOS-safe helper (handles legacy postmeta fallback + migration). + $existing_sites = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_created_sites'); if (!is_array($existing_sites)) { return; } @@ -610,7 +621,8 @@ private function update_completed_site($order_id, $site_info) { } } - update_post_meta($order_id, '_iwp_created_sites', $existing_sites); + // Persist via the HPOS-safe helper so the write reaches the active store. + IWP_Woo_HPOS::update_order_meta($order_id, '_iwp_created_sites', $existing_sites); } /** @@ -678,6 +690,12 @@ public function get_order_sites($order_id) { 'site_type' => $db_site->site_type ?? 'paid', 'source' => $db_site->source, 'plan_id' => $db_site->plan_id, + // Pass api_response through so the render layer can + // decode the failure message on demand for failed + // sites (via IWP_Site_Manager::resolve_failure_message). + // No precompute here — the work only runs when a + // failed card actually renders. + 'api_response' => $db_site->api_response, ); $unique_key = 'site_' . $db_site->site_id; @@ -688,8 +706,10 @@ public function get_order_sites($order_id) { } } - // SECOND PRIORITY: Load from order meta _iwp_sites_created (backward compatibility) - $order_sites = get_post_meta($order_id, '_iwp_sites_created', true); + // SECOND PRIORITY: Load from order meta _iwp_sites_created (backward compatibility). + // Use the HPOS-safe helper so reads resolve under either data store and + // any pre-HPOS-fix postmeta-only value is migrated forward on first read. + $order_sites = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_sites_created'); IWP_Logger::debug('Found sites in _iwp_sites_created', 'site-manager', array('count' => is_array($order_sites) ? count($order_sites) : 0)); if (is_array($order_sites)) { @@ -717,9 +737,11 @@ public function get_order_sites($order_id) { } // Only add legacy sites if no sites were found from the order processor - // This prevents duplicates while maintaining backward compatibility + // This prevents duplicates while maintaining backward compatibility. + // HPOS-safe read: helper covers both wp_wc_orders_meta and wp_postmeta + // and self-heals postmeta-only values written by older plugin builds. if (empty($all_sites)) { - $legacy_sites = get_post_meta($order_id, '_iwp_created_sites', true); + $legacy_sites = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_created_sites'); IWP_Logger::debug('Found sites in _iwp_created_sites (legacy)', 'site-manager', array('count' => is_array($legacy_sites) ? count($legacy_sites) : 0)); if (is_array($legacy_sites)) { foreach ($legacy_sites as $legacy_site) { @@ -757,6 +779,38 @@ public function get_order_sites($order_id) { * @param array $site_data Site data from order processor * @return array|null Transformed site data or null if invalid */ + /** + * Resolve a customer-facing failure message for a failed site row. + * + * Single source of truth: the per-site `api_response` column on + * wp_iwp_sites. The failure path stores `['error' => $msg]` here at + * the instant the API call fails (see create_site_with_tracking() + * lines 88-95) and the success path overwrites the column with the + * full success response (line 119) — so the column always reflects + * the row's *current* state. No order-meta fallback, no per-product + * lookup, no stale-data risk. + * + * Runs the resolved string through IWP_API_Client::humanize_error() + * for legacy rows whose error was stored before the make_request + * humanize fix landed (idempotent for already-humanized text). + * + * @param mixed $api_response_raw JSON string or array from + * wp_iwp_sites.api_response. + * @return string|null Friendly error message, or null when the + * column carries no `error` key (i.e. the row + * either succeeded or has no API response yet). + */ + public static function resolve_failure_message($api_response_raw) { + if (empty($api_response_raw)) { + return null; + } + $decoded = is_string($api_response_raw) ? json_decode($api_response_raw, true) : $api_response_raw; + if (!is_array($decoded) || empty($decoded['error'])) { + return null; + } + return $decoded['error']; + } + private function transform_site_data_for_frontend($site_data) { if (!is_array($site_data) || !isset($site_data['site_data'])) { return null; diff --git a/includes/core/class-iwp-sites-model.php b/includes/core/class-iwp-sites-model.php index 4e8f0d2..59a3c86 100644 --- a/includes/core/class-iwp-sites-model.php +++ b/includes/core/class-iwp-sites-model.php @@ -17,6 +17,66 @@ class IWP_Sites_Model { private static $table_name; + /** + * wpdb format hint per column. + * + * Required because WP core's `wp_set_wpdb_vars()` (wp-includes/load.php) + * pre-populates `$wpdb->field_types['site_id'] = '%d'` and + * `field_types['user_id'] = '%d'` for multisite. When `$wpdb->insert()` + * or `$wpdb->update()` is called WITHOUT an explicit `$format`, wpdb + * falls back to that registry by column NAME (see + * wp-includes/class-wpdb.php:2898) — regardless of what the actual + * column type is in our custom table. + * + * Net effect without an explicit format: any non-numeric string + * written to `site_id` (e.g. "pending-XYZ" placeholder) is cast to + * integer `0` in PHP before the SQL is built. Once one such row + * exists, the UNIQUE constraint on `site_id` blocks every subsequent + * failed-pending insert with a duplicate-key error. + * + * Pass `self::format_for_data($data)` as the third arg to insert() + * and the fourth arg to update() to override the lookup. + * + * @var array + */ + private static $column_formats = array( + 'site_id' => '%s', + 'site_url' => '%s', + 'wp_username' => '%s', + 'wp_password' => '%s', + 'wp_admin_url' => '%s', + 's_hash' => '%s', + 'status' => '%s', + 'site_type' => '%s', + 'task_id' => '%s', + 'snapshot_slug' => '%s', + 'plan_id' => '%s', + 'product_id' => '%d', + 'order_id' => '%d', + 'user_id' => '%d', + 'source' => '%s', + 'source_data' => '%s', + 'is_pool' => '%d', + 'is_reserved' => '%d', + 'expiry_hours' => '%d', + 'api_response' => '%s', + 'created_at' => '%s', + 'updated_at' => '%s', + ); + + /** + * Build a positional `$format` array for wpdb based on $data's keys. + * Falls back to '%s' for any column not in the static map (caller + * passed an unexpected column — let wpdb treat it as a string). + */ + private static function format_for_data($data) { + $formats = array(); + foreach (array_keys($data) as $col) { + $formats[] = isset(self::$column_formats[$col]) ? self::$column_formats[$col] : '%s'; + } + return $formats; + } + public static function init() { global $wpdb; self::$table_name = $wpdb->prefix . 'iwp_sites'; @@ -42,32 +102,39 @@ public static function create($data) { $site_data = wp_parse_args($data, $defaults); + $insert_data = array( + 'site_id' => sanitize_text_field($site_data['site_id']), + 'site_url' => !empty($site_data['site_url']) ? esc_url_raw($site_data['site_url']) : null, + 'wp_username' => !empty($site_data['wp_username']) ? sanitize_text_field($site_data['wp_username']) : null, + 'wp_password' => !empty($site_data['wp_password']) ? $site_data['wp_password'] : null, + 'wp_admin_url' => !empty($site_data['wp_admin_url']) ? esc_url_raw($site_data['wp_admin_url']) : null, + 's_hash' => !empty($site_data['s_hash']) ? sanitize_text_field($site_data['s_hash']) : null, + 'status' => sanitize_text_field($site_data['status']), + 'site_type' => sanitize_text_field($site_data['site_type']), + 'task_id' => !empty($site_data['task_id']) ? sanitize_text_field($site_data['task_id']) : null, + 'snapshot_slug' => !empty($site_data['snapshot_slug']) ? sanitize_text_field($site_data['snapshot_slug']) : null, + 'plan_id' => !empty($site_data['plan_id']) ? sanitize_text_field($site_data['plan_id']) : null, + 'product_id' => !empty($site_data['product_id']) ? intval($site_data['product_id']) : null, + 'order_id' => !empty($site_data['order_id']) ? intval($site_data['order_id']) : null, + 'user_id' => intval($site_data['user_id']), + 'source' => sanitize_text_field($site_data['source']), + 'source_data' => !empty($site_data['source_data']) ? wp_json_encode($site_data['source_data']) : null, + 'is_pool' => intval($site_data['is_pool']), + 'is_reserved' => intval($site_data['is_reserved']), + 'expiry_hours' => !empty($site_data['expiry_hours']) ? intval($site_data['expiry_hours']) : null, + 'api_response' => !empty($site_data['api_response']) ? wp_json_encode($site_data['api_response']) : null, + 'created_at' => $site_data['created_at'], + 'updated_at' => $site_data['updated_at'] + ); + + // Explicit $format overrides WP core's field_types['site_id']='%d' + // lookup (see $column_formats docblock above) so the "pending-XYZ" + // placeholder used by IWP_Site_Manager::create_site_with_tracking + // stores as the actual string instead of being cast to integer 0. $result = $wpdb->insert( self::$table_name, - array( - 'site_id' => sanitize_text_field($site_data['site_id']), - 'site_url' => !empty($site_data['site_url']) ? esc_url_raw($site_data['site_url']) : null, - 'wp_username' => !empty($site_data['wp_username']) ? sanitize_text_field($site_data['wp_username']) : null, - 'wp_password' => !empty($site_data['wp_password']) ? $site_data['wp_password'] : null, - 'wp_admin_url' => !empty($site_data['wp_admin_url']) ? esc_url_raw($site_data['wp_admin_url']) : null, - 's_hash' => !empty($site_data['s_hash']) ? sanitize_text_field($site_data['s_hash']) : null, - 'status' => sanitize_text_field($site_data['status']), - 'site_type' => sanitize_text_field($site_data['site_type']), - 'task_id' => !empty($site_data['task_id']) ? sanitize_text_field($site_data['task_id']) : null, - 'snapshot_slug' => !empty($site_data['snapshot_slug']) ? sanitize_text_field($site_data['snapshot_slug']) : null, - 'plan_id' => !empty($site_data['plan_id']) ? sanitize_text_field($site_data['plan_id']) : null, - 'product_id' => !empty($site_data['product_id']) ? intval($site_data['product_id']) : null, - 'order_id' => !empty($site_data['order_id']) ? intval($site_data['order_id']) : null, - 'user_id' => intval($site_data['user_id']), - 'source' => sanitize_text_field($site_data['source']), - 'source_data' => !empty($site_data['source_data']) ? wp_json_encode($site_data['source_data']) : null, - 'is_pool' => intval($site_data['is_pool']), - 'is_reserved' => intval($site_data['is_reserved']), - 'expiry_hours' => !empty($site_data['expiry_hours']) ? intval($site_data['expiry_hours']) : null, - 'api_response' => !empty($site_data['api_response']) ? wp_json_encode($site_data['api_response']) : null, - 'created_at' => $site_data['created_at'], - 'updated_at' => $site_data['updated_at'] - ) + $insert_data, + self::format_for_data($insert_data) ); if ($result === false) { @@ -103,10 +170,21 @@ public static function update($site_id, $data) { $sanitized_data['updated_at'] = current_time('mysql'); error_log('IWP DEBUG: sites model update() - About to call wpdb->update with sanitized data'); + + // Explicit $format (4th arg) and $where_format (5th arg) both + // override WP core's field_types['site_id']='%d' lookup. Without + // the 4th, a site_id present in $sanitized_data (e.g. when + // create_site_with_tracking updates the placeholder to the real + // site_id) would be cast to int in the SET clause. Without the + // 5th, the WHERE clause would match against integer 0 instead of + // the actual stored placeholder string — UPDATE would silently + // affect 0 rows. See $column_formats docblock above. $result = $wpdb->update( self::$table_name, $sanitized_data, - array('site_id' => sanitize_text_field($site_id)) + array('site_id' => sanitize_text_field($site_id)), + self::format_for_data($sanitized_data), + array('%s') ); error_log('IWP DEBUG: sites model update() - wpdb->update result: ' . ($result !== false ? 'SUCCESS' : 'FAILED')); @@ -183,7 +261,157 @@ public static function trash($site_id, $api_client = null) { $api_client->delete_site($site_id); } - return self::update($site_id, array('status' => self::STATUS_TRASHED)); + if (self::get_by_site_id($site_id)) { + return self::update($site_id, array('status' => self::STATUS_TRASHED)); + } + + // Orphan: site lives only in order meta. Record a deletion marker + // populated with whatever metadata we can recover from the order + // entry (URL, credentials, order, customer, dates) so the Trash + // view at admin.php?page=instawp-sites&status=trashed shows + // meaningful details instead of an empty row with just the site_id. + // Tombstone-specific fields (site_id, status, source) overwrite + // anything the recovery returned for those keys. + $tombstone = array_merge( + self::collect_orphan_metadata($site_id), + array( + 'site_id' => $site_id, + 'status' => self::STATUS_TRASHED, + 'source' => 'order_meta', + ) + ); + + return (bool) self::create($tombstone); + } + + /** + * Pull whatever metadata exists in WooCommerce order meta for $site_id + * and map it onto wp_iwp_sites columns. Used by trash() to give orphan- + * tombstone rows real context when no DB row existed at delete time — + * without this, the Trash filter view shows empty cells for every + * orphan because only site_id/status/source were populated. + * + * Same data path as IWP_Sites_List_Table::get_sites_from_orders(): + * IWP_Woo_HPOS::get_orders_with_meta() returns one indexed row per + * matching order under either data store, with legacy postmeta + * fallback. Entry-shape detection mirrors format_order_site() — both + * the new wrapped form (['site_data' => [...]]) and the legacy flat + * form are handled. + * + * Always returns at least {site_url, wp_admin_url} pointing at the + * InstaWP dashboard for $site_id, so the Trash row is always + * actionable even when the lookup matched nothing in order meta. + * + * @param string $site_id InstaWP site identifier. + * @return array Subset of wp_iwp_sites columns. + */ + private static function collect_orphan_metadata($site_id) { + $extracted = array(); + $matched = false; + + if (class_exists('IWP_Woo_HPOS')) { + $rows = IWP_Woo_HPOS::get_orders_with_meta(array( + '_iwp_sites_created', + '_iwp_created_sites', + )); + + foreach ($rows as $row) { + $sites_data = maybe_unserialize($row->meta_value); + if (!is_array($sites_data)) { + continue; + } + + foreach ($sites_data as $entry) { + $inner = (isset($entry['site_data']) && is_array($entry['site_data'])) + ? $entry['site_data'] + : $entry; + + $entry_site_id = isset($inner['site_id']) ? $inner['site_id'] + : (isset($inner['id']) ? $inner['id'] : ''); + if ((string) $entry_site_id !== (string) $site_id) { + continue; + } + + $matched = true; + + $extracted['order_id'] = (int) $row->order_id; + if (!empty($entry['product_id'])) { + $extracted['product_id'] = (int) $entry['product_id']; + } + + // Source field name → wp_iwp_sites column. First non- + // empty value wins; alternative source-field names + // accommodate older entry shapes. + $field_map = array( + 'site_url' => array('wp_url', 'site_url'), + 'wp_username' => array('wp_username'), + 'wp_password' => array('wp_password'), + 'wp_admin_url' => array('wp_admin_url'), + 's_hash' => array('s_hash'), + 'plan_id' => array('plan_id'), + 'snapshot_slug' => array('snapshot_slug'), + 'task_id' => array('task_id'), + 'created_at' => array('created_at'), + ); + foreach ($field_map as $col => $candidates) { + foreach ($candidates as $key) { + if (!empty($inner[$key])) { + $extracted[$col] = $inner[$key]; + break; + } + } + } + + // Pull customer + order date from the WC_Order itself. + if (function_exists('wc_get_order')) { + $order = wc_get_order((int) $row->order_id); + if ($order) { + $extracted['user_id'] = (int) $order->get_customer_id(); + if (empty($extracted['created_at'])) { + $created = $order->get_date_created(); + if ($created) { + $extracted['created_at'] = $created->format('Y-m-d H:i:s'); + } + } + } + } + + // Forensic snapshot — once order meta is cleaned up, the + // tombstone is the only place this entry survives. + // create() JSON-encodes arrays, so pass an array, not a + // pre-encoded string. + $extracted['source_data'] = array( + 'order_id' => (int) $row->order_id, + 'meta_key' => $row->meta_key, + 'entry' => $entry, + 'trashed_at' => current_time('mysql'), + ); + + break 2; + } + } + } + + // Derive wp_admin_url from site_url when the entry didn't carry + // one (older order-meta payloads usually don't). + if ($matched && empty($extracted['wp_admin_url']) && !empty($extracted['site_url']) && function_exists('trailingslashit')) { + $extracted['wp_admin_url'] = trailingslashit($extracted['site_url']) . 'wp-admin'; + } + + // Last-resort fallback when order meta yielded nothing: at least + // give admins a clickable InstaWP dashboard link for $site_id so + // the Trash row is never an empty cell. Both site_url and + // wp_admin_url get the same dashboard URL — the actual WP site + // URL isn't recoverable without the order-meta entry, but the + // dashboard is still actionable (lets the admin investigate / + // restore on the InstaWP side). + if (!$matched && defined('IWP_PLUGIN_APP_URL')) { + $dashboard = IWP_PLUGIN_APP_URL . '/sites/' . rawurlencode($site_id); + $extracted['site_url'] = $dashboard; + $extracted['wp_admin_url'] = $dashboard; + } + + return $extracted; } /** diff --git a/includes/frontend/class-iwp-frontend.php b/includes/frontend/class-iwp-frontend.php index fd86ea5..3902757 100644 --- a/includes/frontend/class-iwp-frontend.php +++ b/includes/frontend/class-iwp-frontend.php @@ -453,8 +453,9 @@ public function display_order_sites_after_table($order) { * @param WC_Order $order */ private function display_deferred_onboarding($order) { - $order_id = $order->get_id(); - $deferred_items = get_post_meta($order_id, '_iwp_deferred_items', true); + $order_id = $order->get_id(); + // HPOS-safe read — covers both data stores and any legacy postmeta value. + $deferred_items = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_deferred_items'); if (empty($deferred_items) || !is_array($deferred_items)) { return; @@ -807,7 +808,14 @@ private function render_customer_site_card($site, $context = 'order-view') { } elseif ($status === 'failed') { echo '
'; - echo '

' . __('Sorry, there was an issue creating your site. Please contact support for assistance.', 'iwp-wp-integration') . '

'; + $msg = class_exists('IWP_Site_Manager') + ? IWP_Site_Manager::resolve_failure_message($site['api_response'] ?? null) + : null; + if ($msg) { + echo '

' . esc_html($msg) . '

'; + } else { + echo '

' . __('Sorry, there was an issue creating your site. Please contact support for assistance.', 'iwp-wp-integration') . '

'; + } echo '
'; } @@ -884,7 +892,8 @@ private function render_domain_mapping_modal($order_id) { '; echo '

' . __('Mapped Domains:', 'iwp-wp-integration') . '

'; @@ -1039,7 +1048,13 @@ public function add_sites_to_emails($order, $sent_to_admin, $plain_text, $email) } elseif ($status === 'progress') { echo '

' . __('Your site is being created. You will receive another email when it\'s ready.', 'iwp-wp-integration') . '

'; } elseif ($status === 'failed') { - echo '

' . __('There was an issue creating your site. Please contact support.', 'iwp-wp-integration') . '

'; + $msg = class_exists('IWP_Site_Manager') + ? IWP_Site_Manager::resolve_failure_message($site['api_response'] ?? null) + : null; + $err_text = $msg + ? esc_html($msg) + : __('There was an issue creating your site. Please contact support.', 'iwp-wp-integration'); + echo '

' . $err_text . '

'; } echo ''; } @@ -1056,20 +1071,30 @@ public function display_customer_sites() { return; } - // Check for orders with deferred items (pending setup) - $deferred_orders = wc_get_orders(array( + // Check for orders with deferred items (pending setup). + // Routed through IWP_Woo_HPOS::get_orders() so the meta_query is + // evaluated correctly on both CPT and HPOS data stores and picks up + // legacy values that may still live in wp_postmeta. + $deferred_orders = IWP_Woo_HPOS::get_orders(array( 'customer_id' => $customer_id, 'limit' => 5, 'status' => array('completed', 'processing'), - 'meta_key' => '_iwp_deferred_items', 'orderby' => 'date', 'order' => 'DESC', + 'meta_query' => array( + array( + 'key' => '_iwp_deferred_items', + 'compare' => 'EXISTS', + ), + ), )); // Show pending setup notice with link to order if (!empty($deferred_orders)) { foreach ($deferred_orders as $def_order) { - $deferred_items = get_post_meta($def_order->get_id(), '_iwp_deferred_items', true); + // Read via the HPOS-safe helper: covers both wp_wc_orders_meta + // and wp_postmeta, with forward-migration for legacy values. + $deferred_items = IWP_Woo_HPOS::get_order_meta($def_order, '_iwp_deferred_items'); if (!empty($deferred_items) && is_array($deferred_items)) { $order_url = $def_order->get_view_order_url(); $count = count($deferred_items); @@ -1090,17 +1115,19 @@ public function display_customer_sites() { } } - // Get customer's orders with sites - $orders = wc_get_orders(array( + // Get customer's orders with created sites. + // Same compat helper as above — keeps the standard meta_query shape at + // the call site while handling the CPT vs HPOS split and legacy data. + $orders = IWP_Woo_HPOS::get_orders(array( 'customer_id' => $customer_id, - 'limit' => -1, - 'status' => array('completed', 'processing'), - 'meta_query' => array( + 'limit' => -1, + 'status' => array('completed', 'processing'), + 'meta_query' => array( array( - 'key' => '_iwp_created_sites', - 'compare' => 'EXISTS' - ) - ) + 'key' => '_iwp_created_sites', + 'compare' => 'EXISTS', + ), + ), )); if (empty($orders) && empty($deferred_orders)) { diff --git a/includes/frontend/class-iwp-onboarding.php b/includes/frontend/class-iwp-onboarding.php index 6046b01..2b632e1 100644 --- a/includes/frontend/class-iwp-onboarding.php +++ b/includes/frontend/class-iwp-onboarding.php @@ -53,9 +53,11 @@ public function render_shortcode($atts) { ''; } - $order_id = $order->get_id(); - $deferred_items = get_post_meta($order_id, '_iwp_deferred_items', true); - $sites_created = get_post_meta($order_id, '_iwp_sites_created', true); + $order_id = $order->get_id(); + // HPOS-safe reads so the onboarding page resolves correctly under both + // data stores and picks up any legacy postmeta-only values. + $deferred_items = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_deferred_items'); + $sites_created = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_sites_created'); $redirect_url = !empty($atts['redirect']) ? esc_url($atts['redirect']) : ''; ob_start(); @@ -211,8 +213,8 @@ public function handle_create_site() { wp_send_json_error(array('message' => __('You do not have permission to access this order.', 'iwp-wp-integration'))); } - // Get deferred items - $deferred_items = get_post_meta($order_id, '_iwp_deferred_items', true); + // Get deferred items — HPOS-safe read covers both data stores. + $deferred_items = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_deferred_items'); if (empty($deferred_items) || !isset($deferred_items[$item_index])) { wp_send_json_error(array('message' => __('No pending site setup found for this item.', 'iwp-wp-integration'))); } @@ -318,11 +320,11 @@ public function handle_create_site() { 'order_id' => $order_id, 'error' => $result->get_error_message(), )); - wp_send_json_error(array('message' => $result->get_error_message())); + wp_send_json_error(array('message' => IWP_API_Client::humanize_error($result))); } - // Store result in _iwp_sites_created - $sites_created = get_post_meta($order_id, '_iwp_sites_created', true); + // Store result in _iwp_sites_created — HPOS-safe read/write. + $sites_created = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_sites_created'); if (!is_array($sites_created)) { $sites_created = array(); } @@ -333,15 +335,16 @@ public function handle_create_site() { 'action' => 'created', 'source' => 'onboarding', ); - update_post_meta($order_id, '_iwp_sites_created', $sites_created); + IWP_Woo_HPOS::update_order_meta($order_id, '_iwp_sites_created', $sites_created); - // Remove this item from deferred list + // Remove this item from deferred list. Use delete_order_meta when the + // list is emptied so we don't leave an orphan empty-array row. unset($deferred_items[$item_index]); $deferred_items = array_values($deferred_items); // Re-index if (empty($deferred_items)) { - delete_post_meta($order_id, '_iwp_deferred_items'); + IWP_Woo_HPOS::delete_order_meta($order_id, '_iwp_deferred_items'); } else { - update_post_meta($order_id, '_iwp_deferred_items', $deferred_items); + IWP_Woo_HPOS::update_order_meta($order_id, '_iwp_deferred_items', $deferred_items); } // Add order note @@ -402,7 +405,8 @@ public function maybe_redirect_to_onboarding() { return; } - $deferred_items = get_post_meta($order_id, '_iwp_deferred_items', true); + // HPOS-safe read of the deferred-items list on the order. + $deferred_items = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_deferred_items'); if (empty($deferred_items) || !is_array($deferred_items)) { return; } diff --git a/includes/integrations/woocommerce/class-iwp-woo-hpos.php b/includes/integrations/woocommerce/class-iwp-woo-hpos.php index fe0f817..39d3730 100644 --- a/includes/integrations/woocommerce/class-iwp-woo-hpos.php +++ b/includes/integrations/woocommerce/class-iwp-woo-hpos.php @@ -16,6 +16,55 @@ */ class IWP_Woo_HPOS { + /** + * Resolve the first argument of the meta helpers to a loaded WC_Abstract_Order. + * + * Accepts either an order ID (int or numeric string) or an already-loaded + * WC_Abstract_Order (which covers WC_Order, WC_Subscription, + * WC_Order_Refund). Callers in loops should pass the object directly to + * avoid a redundant wc_get_order() lookup per iteration. + * + * Strict input contract: + * - WC_Abstract_Order instance → returned as-is. + * - Positive numeric (int or "123") → loaded; returns false (debug + * log) if no order exists. + * - Anything else (empty, 0, negative, + * non-numeric, array, null, object) → warn-log the bad type, false. + * + * Assumes WooCommerce is loaded — the class file is only required by + * iwp-wp-integration.php inside class_exists('WooCommerce'). + * + * @param int|numeric-string|WC_Abstract_Order $order_or_id + * @return WC_Abstract_Order|false + */ + private static function resolve_order_arg($order_or_id) { + if ($order_or_id instanceof WC_Abstract_Order) { + return $order_or_id; + } + + if (is_numeric($order_or_id) && ((int) $order_or_id) > 0) { + $order = self::get_order((int) $order_or_id); + if ($order instanceof WC_Abstract_Order) { + return $order; + } + if (class_exists('IWP_Logger')) { + IWP_Logger::debug('HPOS helper: order not found for ID', 'hpos-compat', array( + 'order_id' => (int) $order_or_id, + )); + } + return false; + } + + // Anything else is a caller bug — log loud so it surfaces in dev. + if (class_exists('IWP_Logger')) { + IWP_Logger::warning('HPOS helper: invalid order argument', 'hpos-compat', array( + 'type' => is_object($order_or_id) ? get_class($order_or_id) : gettype($order_or_id), + 'value' => is_scalar($order_or_id) ? (string) $order_or_id : null, + )); + } + return false; + } + /** * Constructor */ @@ -64,27 +113,246 @@ public static function get_order($order_id) { } /** - * Get orders (HPOS compatible) + * Get orders — transparent HPOS/CPT compat wrapper around wc_get_orders(). * - * @param array $args Query arguments - * @return WC_Order[] + * Callers pass a normal wc_get_orders() arg array, INCLUDING `meta_query` + * in its standard WP_Query shape. This wrapper handles the data-store and + * legacy-data quirks so callers don't have to branch on is_hpos_enabled(). + * + * Why we don't pass `meta_query` through to wc_get_orders() on either store: + * + * - CPT data store: the base `WC_Data_Store_WP::get_wp_query_args()` + * explicitly strips `meta_query` from $query_vars + * (class-wc-data-store-wp.php:284 — `'meta_query' === $key` → continue). + * WC 9.2+ additionally emits _doing_it_wrong when it sees the arg + * (class-wc-order-data-store-cpt.php:1076-1100). So `meta_query` + * never filters anything on CPT via the top-level arg. + * + * - HPOS authoritative: `OrdersTableQuery` DOES support `meta_query` + * natively (OrdersTableQuery.php:791-793 → OrdersTableMetaQuery), + * but it only joins wp_wc_orders_meta. Orders whose IWP meta was + * written by pre-HPOS-fix plugin builds (via update_post_meta()) have + * their values in wp_postmeta and get silently skipped by the native + * HPOS query. + * + * Unified strategy (no branching on data store): + * + * 1. Extract `meta_query` from $args before calling wc_get_orders(). + * 2. Fetch orders using only first-class args (customer_id, status, limit, …). + * These are supported identically by both data stores with no notices. + * 3. Evaluate the meta filter in PHP via self::get_order_meta(). That + * reader already handles the active-store / legacy-postmeta fallback + * and opportunistically migrates legacy values forward on first read, + * so a legacy order becomes visible once and repairs its own storage + * on the way through. + * + * Supported meta_query shape: an array of clauses each of the form + * array('key' => '', 'compare' => 'EXISTS') + * plus an optional top-level 'relation' key (treated as OR, which is this + * plugin's only usage). Clauses with other `compare` operators are logged + * and skipped — extend the evaluator when a caller needs richer operators + * rather than silently mis-filtering. + * + * Performance / memory: because the meta filter runs in PHP on the result + * of the wc_get_orders() call, callers should bound the query with + * first-class args (customer_id, status, limit, date_created, …) before + * relying on meta_query. Passing `limit => -1` without any other scope is + * safe only for small result sets (e.g. per-customer order lists). For + * site-wide queries on large stores, paginate or add a customer/status + * scope. + * + * @param array $args Query arguments. + * @return WC_Order[]|int[] WC_Order objects by default; order IDs when the + * caller passes 'return' => 'ids'. */ public static function get_orders($args = array()) { $default_args = array( - 'limit' => -1, + 'limit' => -1, 'orderby' => 'date', - 'order' => 'DESC', - 'return' => 'objects', + 'order' => 'DESC', + 'return' => 'objects', ); $args = wp_parse_args($args, $default_args); - if (self::is_hpos_enabled()) { - return wc_get_orders($args); - } else { - // Fallback for legacy post-based orders - return wc_get_orders($args); + // Pull meta_query out before wc_get_orders() runs. Under CPT this + // avoids the 9.2 notice; under HPOS this avoids the legacy-postmeta + // blind spot. We evaluate it ourselves below. + $meta_query = array(); + if (!empty($args['meta_query']) && is_array($args['meta_query'])) { + $meta_query = $args['meta_query']; + unset($args['meta_query']); + } + + // Collect the keys to EXISTS-check. The plugin's meta_query usage is + // limited to EXISTS clauses (optionally OR'd), so a flat list is + // enough. A non-EXISTS compare indicates a caller this helper isn't + // designed for; we log and skip that clause rather than pretend. + $exists_keys = array(); + foreach ($meta_query as $clause_key => $clause) { + if ($clause_key === 'relation') { + continue; // Top-level relation — we always OR EXISTS keys. + } + if (!is_array($clause) || !isset($clause['key'])) { + continue; + } + $compare = isset($clause['compare']) ? strtoupper($clause['compare']) : '='; + if ($compare !== 'EXISTS') { + if (class_exists('IWP_Logger')) { + IWP_Logger::warning('get_orders received unsupported meta_query compare; ignoring clause', 'hpos-compat', array( + 'compare' => $compare, + 'key' => $clause['key'], + )); + } + continue; + } + $exists_keys[] = $clause['key']; + } + + // PHP-side meta filtering needs the hydrated WC_Order. If the caller + // asked for 'ids' we temporarily switch to objects, filter, then + // project back to IDs at the end. + $original_return = isset($args['return']) ? $args['return'] : 'objects'; + if (!empty($exists_keys)) { + $args['return'] = 'objects'; + } + + $orders = wc_get_orders($args); + + // Fast path: no meta filter requested, or WC returned nothing to filter. + if (empty($exists_keys) || empty($orders)) { + return $orders; } + + $before_count = count($orders); + $filtered = array(); + foreach ($orders as $order) { + foreach ($exists_keys as $meta_key) { + // self::get_order_meta() — not $order->get_meta() — so the + // wp_postmeta fallback + forward-migrate runs for legacy + // values. We pass the $order object directly; the resolver + // short-circuits on the instanceof branch so there's no + // redundant wc_get_order() lookup per iteration. empty() + // covers every "not set" shape ('', null, [], false) — the + // filtered meta keys in this plugin are always arrays, so + // the 0 / '0' false-positives of empty() don't bite. + if (!empty(self::get_order_meta($order, $meta_key))) { + $filtered[] = $order; + break; // OR semantics — any match is enough. + } + } + } + + if (class_exists('IWP_Logger')) { + IWP_Logger::debug('get_orders meta_query filter applied', 'hpos-compat', array( + 'exists_keys' => $exists_keys, + 'hpos_enabled' => self::is_hpos_enabled(), + 'before_count' => $before_count, + 'after_count' => count($filtered), + )); + } + + // Restore the caller's requested return shape. + if ($original_return === 'ids') { + return array_map(function ($order) { return $order->get_id(); }, $filtered); + } + + return $filtered; + } + + /** + * Fetch (order_id, meta_key, meta_value) rows for any order that has at + * least one of the supplied meta keys, regardless of which order data + * store WooCommerce is configured to use. Single round trip in both modes. + * + * Returns raw stdClass rows so callers can keep their own + * maybe_unserialize / format / dedupe loop. This is the high-throughput + * sibling of get_orders() — use it on call sites where the matching set + * is far smaller than total orders (e.g. admin list pages on stores with + * 10k+ orders) and the caller doesn't need every order hydrated as a + * WC_Order. get_orders() loads the entire result set into objects before + * filtering in PHP; that's appropriate when the meta_query is incidental, + * but the wrong tool when the meta is the primary selector. + * + * Under CPT-authoritative: SELECT against wp_posts + wp_postmeta. + * Under HPOS-authoritative: SELECT against wc_orders + wc_orders_meta, + * UNION-ed with wp_posts + wp_postmeta entries that have no + * corresponding wc_orders_meta row. The UNION half catches legacy + * values written by pre-HPOS-fix plugin builds via update_post_meta() + * that haven't been forward-migrated yet, and the NOT EXISTS clause + * de-dupes when sync mode mirrors the same value into both stores. + * + * @param string[] $meta_keys Meta keys to match (IN list). Caller- + * provided literals only — values are bound + * via prepare(). Empty array short-circuits. + * @return stdClass[] Each row has order_id (int), meta_key (string), + * meta_value (string — still serialized; caller + * decides whether to maybe_unserialize). + */ + public static function get_orders_with_meta(array $meta_keys) { + global $wpdb; + + $meta_keys = array_values(array_unique(array_filter($meta_keys, 'is_string'))); + if (empty($meta_keys)) { + return array(); + } + + $placeholders = implode(',', array_fill(0, count($meta_keys), '%s')); + + if (!self::is_hpos_enabled()) { + // CPT path — same shape as the historic raw SQL. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $sql = $wpdb->prepare( + "SELECT p.ID AS order_id, pm.meta_key AS meta_key, pm.meta_value AS meta_value + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id + WHERE p.post_type = 'shop_order' + AND pm.meta_key IN ($placeholders) + AND pm.meta_value IS NOT NULL + AND pm.meta_value <> ''", + $meta_keys + ); + return (array) $wpdb->get_results($sql); + } + + // HPOS path — query the active store, then UNION with wp_postmeta + // rows the active store doesn't have. Table names come from + // OrderUtil::get_table_for_* (same primitive class-iwp-database.php + // already uses), so they're trusted; meta keys are bound via + // prepare() (twice — once per IN clause in the UNION). + $orders_table = \Automattic\WooCommerce\Utilities\OrderUtil::get_table_for_orders(); + $meta_table = \Automattic\WooCommerce\Utilities\OrderUtil::get_table_for_order_meta(); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $sql = $wpdb->prepare( + "SELECT o.id AS order_id, m.meta_key AS meta_key, m.meta_value AS meta_value + FROM {$orders_table} o + INNER JOIN {$meta_table} m ON o.id = m.order_id + WHERE o.type = 'shop_order' + AND m.meta_key IN ($placeholders) + AND m.meta_value IS NOT NULL + AND m.meta_value <> '' + + UNION ALL + + SELECT p.ID AS order_id, pm.meta_key AS meta_key, pm.meta_value AS meta_value + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id + WHERE p.post_type = 'shop_order' + AND pm.meta_key IN ($placeholders) + AND pm.meta_value IS NOT NULL + AND pm.meta_value <> '' + AND NOT EXISTS ( + SELECT 1 FROM {$meta_table} m2 + WHERE m2.order_id = p.ID + AND m2.meta_key = pm.meta_key + AND m2.meta_value IS NOT NULL + AND m2.meta_value <> '' + )", + array_merge($meta_keys, $meta_keys) + ); + + return (array) $wpdb->get_results($sql); } /** @@ -181,61 +449,127 @@ public static function get_orders_by_product($product_id, $args = array()) { } /** - * Get order meta (HPOS compatible) + * Get order meta through the active WC data store, with legacy postmeta fallback. * - * @param int $order_id Order ID - * @param string $key Meta key - * @param bool $single Whether to return single value - * @return mixed + * Lookup order: + * 1. $order->get_meta($key) — reads from the data store the order was + * hydrated from (wp_wc_orders_meta under HPOS, wp_postmeta under CPT). + * 2. get_post_meta($order_id, $key, true) — fallback for values written + * by pre-HPOS-fix plugin builds that used update_post_meta() directly. + * On a hit under HPOS, the value is migrated forward via + * $order->update_meta_data() + save_meta_data() so subsequent reads + * resolve via the active store without re-hitting this fallback. + * + * @param int|numeric-string|WC_Abstract_Order $order_or_id + * Order ID or a loaded order object. Pass the object in loops to + * avoid a redundant wc_get_order() lookup per iteration. + * @param string $key Meta key. + * @param bool $single Whether to return a single value. + * @return mixed The meta value, or false when the order cannot be + * resolved and neither source has a value. */ - public static function get_order_meta($order_id, $key, $single = true) { - $order = self::get_order($order_id); - + public static function get_order_meta($order_or_id, $key, $single = true) { + $order = self::resolve_order_arg($order_or_id); if (!$order) { return false; } - return $order->get_meta($key, $single); + // Primary: active data store (HPOS or CPT) via the WC order object. + $value = $order->get_meta($key, $single); + if (!empty($value)) { + return $value; + } + + // Legacy fallback: pre-HPOS-fix plugin builds wrote via update_post_meta(). + // Under authoritative HPOS that data never reached wp_wc_orders_meta, so + // $order->get_meta() returns empty above. Read wp_postmeta directly and, + // if we find something, migrate it into the active store so the next + // read resolves cleanly without re-entering this branch. + $order_id = $order->get_id(); + $legacy = get_post_meta($order_id, $key, $single); + if (empty($legacy)) { + return $value; // Preserve the original empty shape ('' or array()). + } + + if (class_exists('IWP_Logger')) { + IWP_Logger::info('Migrating legacy postmeta to active data store', 'hpos-compat', array( + 'order_id' => $order_id, + 'key' => $key, + 'hpos_enabled' => self::is_hpos_enabled(), + 'value_type' => is_array($legacy) ? 'array' : gettype($legacy), + )); + } + + $order->update_meta_data($key, $legacy); + $order->save_meta_data(); + + return $legacy; } /** - * Update order meta (HPOS compatible) + * Update order meta through the active WC data store (HPOS-safe). * - * @param int $order_id Order ID - * @param string $key Meta key - * @param mixed $value Meta value - * @return bool + * Uses $order->save_meta_data() rather than $order->save() so only the + * meta change is persisted — no status-change or order-update lifecycle + * hooks fire. Under HPOS writes to wp_wc_orders_meta; under CPT writes + * to wp_postmeta; under HPOS compat/sync mode WC mirrors to both tables + * on the same save. + * + * @param int|numeric-string|WC_Abstract_Order $order_or_id Order ID or loaded order. + * @param string $key Meta key. + * @param mixed $value Meta value. + * @return bool True on success, false when the order cannot be resolved. */ - public static function update_order_meta($order_id, $key, $value) { - $order = self::get_order($order_id); - + public static function update_order_meta($order_or_id, $key, $value) { + $order = self::resolve_order_arg($order_or_id); if (!$order) { return false; } $order->update_meta_data($key, $value); - $order->save(); - + $order->save_meta_data(); + + if (class_exists('IWP_Logger')) { + IWP_Logger::debug('Order meta written via active data store', 'hpos-compat', array( + 'order_id' => $order->get_id(), + 'key' => $key, + 'hpos_enabled' => self::is_hpos_enabled(), + 'value_type' => is_array($value) ? 'array' : gettype($value), + )); + } + return true; } /** - * Delete order meta (HPOS compatible) + * Delete order meta through the active WC data store (HPOS-safe). * - * @param int $order_id Order ID - * @param string $key Meta key - * @return bool + * Uses $order->save_meta_data() rather than $order->save() so only the + * meta removal is persisted — no status-change or order-update lifecycle + * hooks fire. Under HPOS removes from wp_wc_orders_meta; under CPT removes + * from wp_postmeta; under HPOS compat/sync mode WC mirrors the delete. + * + * @param int|numeric-string|WC_Abstract_Order $order_or_id Order ID or loaded order. + * @param string $key Meta key. + * @return bool True on success, false when the order cannot be resolved. */ - public static function delete_order_meta($order_id, $key) { - $order = self::get_order($order_id); - + public static function delete_order_meta($order_or_id, $key) { + $order = self::resolve_order_arg($order_or_id); if (!$order) { return false; } $order->delete_meta_data($key); - $order->save(); - + $order->save_meta_data(); + + if (class_exists('IWP_Logger')) { + IWP_Logger::debug('Order meta deleted via active data store', 'hpos-compat', array( + 'order_id' => $order->get_id(), + 'key' => $key, + 'hpos_enabled' => self::is_hpos_enabled(), + )); + } + return true; } diff --git a/includes/integrations/woocommerce/class-iwp-woo-order-processor.php b/includes/integrations/woocommerce/class-iwp-woo-order-processor.php index 06adafe..a714fde 100644 --- a/includes/integrations/woocommerce/class-iwp-woo-order-processor.php +++ b/includes/integrations/woocommerce/class-iwp-woo-order-processor.php @@ -114,8 +114,10 @@ private function process_order($order_id, $status) { return; } - // Check if we've already processed this order - $processed = get_post_meta($order_id, '_iwp_processed', true); + // Check if we've already processed this order. + // HPOS-safe read via the helper so the dedupe flag is visible under + // both data stores (CPT and authoritative HPOS). + $processed = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_processed'); if ($processed) { error_log('IWP WooCommerce V2: Order already processed: ' . $order_id); return; @@ -252,24 +254,26 @@ private function process_order($order_id, $status) { } } - // Store results in order meta + // Store results in order meta via the HPOS-safe helpers so the writes + // land in whichever table the active data store owns + // (wp_wc_orders_meta under HPOS, wp_postmeta under CPT). if (!empty($sites_created)) { - update_post_meta($order_id, '_iwp_sites_created', $sites_created); + IWP_Woo_HPOS::update_order_meta($order_id, '_iwp_sites_created', $sites_created); } if (!empty($errors)) { - update_post_meta($order_id, '_iwp_creation_errors', $errors); + IWP_Woo_HPOS::update_order_meta($order_id, '_iwp_creation_errors', $errors); } // Store deferred items for post-purchase onboarding if (!empty($deferred_items)) { - update_post_meta($order_id, '_iwp_deferred_items', $deferred_items); + IWP_Woo_HPOS::update_order_meta($order_id, '_iwp_deferred_items', $deferred_items); error_log('IWP WooCommerce V2: Stored ' . count($deferred_items) . ' deferred item(s) for post-purchase onboarding'); } - // Mark order as processed - update_post_meta($order_id, '_iwp_processed', true); - update_post_meta($order_id, '_iwp_processed_date', current_time('mysql')); + // Mark order as processed (dedupe flag + timestamp). + IWP_Woo_HPOS::update_order_meta($order_id, '_iwp_processed', true); + IWP_Woo_HPOS::update_order_meta($order_id, '_iwp_processed_date', current_time('mysql')); // Add order note $note = $this->generate_order_note($sites_created, $errors, $deferred_items); @@ -392,6 +396,10 @@ private function create_site_for_product($order, $product, $snapshot_slug, $item ); if (is_wp_error($result)) { + $humanized = IWP_API_Client::humanize_error($result); + if ($humanized !== $result->get_error_message()) { + $result = new WP_Error($result->get_error_code(), $humanized); + } return $result; } @@ -537,13 +545,14 @@ private function upgrade_site_plan($order, $product, $site_id, $plan_id, $item) $upgrade_data['s_hash'] = $upgrade_site_data['s_hash']; } - // Add this to order meta as well - $existing_upgrades = get_post_meta($order->get_id(), '_iwp_site_upgrades', true); + // Add this to order meta as well — HPOS-safe read/write so upgrade + // records survive on both data stores. + $existing_upgrades = IWP_Woo_HPOS::get_order_meta($order, '_iwp_site_upgrades'); if (!is_array($existing_upgrades)) { $existing_upgrades = array(); } $existing_upgrades[] = $upgrade_data; - update_post_meta($order->get_id(), '_iwp_site_upgrades', $existing_upgrades); + IWP_Woo_HPOS::update_order_meta($order, '_iwp_site_upgrades', $existing_upgrades); return $upgrade_data; } @@ -655,9 +664,11 @@ public function add_order_meta_box() { */ public function render_order_meta_box($post) { $order_id = $post->ID; - $sites_created = get_post_meta($order_id, '_iwp_sites_created', true); - $errors = get_post_meta($order_id, '_iwp_creation_errors', true); - $processed = get_post_meta($order_id, '_iwp_processed', true); + // HPOS-safe reads via the helper — meta box renders correctly on both + // data stores and picks up legacy postmeta-only values. + $sites_created = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_sites_created'); + $errors = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_creation_errors'); + $processed = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_processed'); // Check if order has site-enabled products $order = wc_get_order($order_id); @@ -810,8 +821,10 @@ public function add_order_columns($columns) { */ public function populate_order_columns($column, $post_id) { if ('iwp_sites' === $column) { - $sites_created = get_post_meta($post_id, '_iwp_sites_created', true); - $errors = get_post_meta($post_id, '_iwp_creation_errors', true); + // HPOS-safe reads via the helper so the Sites column is correct + // on both data stores, including legacy postmeta-only orders. + $sites_created = IWP_Woo_HPOS::get_order_meta($post_id, '_iwp_sites_created'); + $errors = IWP_Woo_HPOS::get_order_meta($post_id, '_iwp_creation_errors'); if (!empty($sites_created)) { echo '✓ ' . count($sites_created) . ' ' . __('sites', 'iwp-woo-v2') . ''; @@ -839,8 +852,8 @@ public function manually_create_sites($order_id) { ); } - // Check if already processed - $processed = get_post_meta($order_id, '_iwp_processed', true); + // Check if already processed — HPOS-safe dedupe read. + $processed = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_processed'); if ($processed) { return array( 'success' => false, @@ -850,13 +863,13 @@ public function manually_create_sites($order_id) { // Temporarily enable auto-create for this manual process error_log('IWP WooCommerce V2: Manually creating sites for order: ' . $order_id); - + // Call the same processing logic but bypass the global setting check $this->process_order_internal($order_id, 'manual'); - - // Check results - $sites_created = get_post_meta($order_id, '_iwp_sites_created', true); - $errors = get_post_meta($order_id, '_iwp_creation_errors', true); + + // Check results via the HPOS-safe helper. + $sites_created = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_sites_created'); + $errors = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_creation_errors'); if (!empty($sites_created)) { $message = sprintf( @@ -905,8 +918,9 @@ private function process_order_internal($order_id, $status) { return; } - // Skip the processed check for manual creation, but log it - $processed = get_post_meta($order_id, '_iwp_processed', true); + // Skip the processed check for manual creation, but log it. + // HPOS-safe read so the dedupe flag is visible on both data stores. + $processed = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_processed'); if ($processed && $status !== 'manual') { error_log('IWP WooCommerce V2: Order already processed: ' . $order_id); return; @@ -999,18 +1013,18 @@ private function process_order_internal($order_id, $status) { } } - // Store results + // Store results via the HPOS-safe helpers. if (!empty($sites_created)) { - update_post_meta($order_id, '_iwp_sites_created', $sites_created); + IWP_Woo_HPOS::update_order_meta($order_id, '_iwp_sites_created', $sites_created); } - + if (!empty($errors)) { - update_post_meta($order_id, '_iwp_creation_errors', $errors); + IWP_Woo_HPOS::update_order_meta($order_id, '_iwp_creation_errors', $errors); } - // Mark as processed - update_post_meta($order_id, '_iwp_processed', true); - update_post_meta($order_id, '_iwp_processed_date', current_time('mysql')); + // Mark as processed (dedupe flag + timestamp). + IWP_Woo_HPOS::update_order_meta($order_id, '_iwp_processed', true); + IWP_Woo_HPOS::update_order_meta($order_id, '_iwp_processed_date', current_time('mysql')); // Add order note $note = $this->generate_order_note($sites_created, $errors); @@ -1059,9 +1073,10 @@ private function reconcile_demo_sites_to_order($order, $upgrade_site_id = null) } } - // SECOND PRIORITY: Check order meta for upgraded sites (for completed orders) + // SECOND PRIORITY: Check order meta for upgraded sites (for completed orders). + // HPOS-safe read covers both tables + legacy postmeta-only values. if (empty($demo_sites)) { - $order_sites = get_post_meta($order_id, '_iwp_sites_created', true); + $order_sites = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_sites_created'); if (is_array($order_sites)) { foreach ($order_sites as $order_site) { if (isset($order_site['site_data']['action']) && $order_site['site_data']['action'] === 'upgraded') { @@ -1173,7 +1188,9 @@ private function reconcile_demo_sites_to_order($order, $upgrade_site_id = null) * @param object $demo_site */ private function add_reconciled_site_to_order_meta($order_id, $demo_site) { - $existing_sites = get_post_meta($order_id, '_iwp_sites_created', true); + // HPOS-safe read so we append to the same list the rest of the plugin + // sees, regardless of which table the active data store owns. + $existing_sites = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_sites_created'); if (!is_array($existing_sites)) { $existing_sites = array(); } @@ -1197,7 +1214,8 @@ private function add_reconciled_site_to_order_meta($order_id, $demo_site) { 'reconciled_at' => current_time('mysql') ); - update_post_meta($order_id, '_iwp_sites_created', $existing_sites); + // HPOS-safe write. + IWP_Woo_HPOS::update_order_meta($order_id, '_iwp_sites_created', $existing_sites); } /** diff --git a/includes/integrations/woocommerce/class-iwp-woo-product-fields.php b/includes/integrations/woocommerce/class-iwp-woo-product-fields.php index 1729d96..2ae983c 100644 --- a/includes/integrations/woocommerce/class-iwp-woo-product-fields.php +++ b/includes/integrations/woocommerce/class-iwp-woo-product-fields.php @@ -19,6 +19,19 @@ */ class IWP_Woo_Product_Fields { + /** + * Customer-facing label for the WooCommerce site-name field. Single + * source of truth — reuse anywhere new code mentions the field by name + * (cart/checkout review, error messages) so the term stays consistent. + * Pre-existing strings that embed the label inside a full sentence are + * left as-is to avoid invalidating existing translations. + * + * @return string + */ + public static function field_label() { + return __('Subdomain', 'iwp-wp-integration'); + } + /** * Constructor */ @@ -206,7 +219,7 @@ public function display_cart_item_data($item_data, $cart_item) { if (!empty($cart_item['iwp_subdomain'])) { $item_data[] = array( - 'key' => __('Subdomain', 'iwp-wp-integration'), + 'key' => self::field_label(), 'value' => esc_html($cart_item['iwp_subdomain']), ); } diff --git a/includes/integrations/woocommerce/class-iwp-woo-subscription-site-manager.php b/includes/integrations/woocommerce/class-iwp-woo-subscription-site-manager.php index 92c1765..708dd9b 100644 --- a/includes/integrations/woocommerce/class-iwp-woo-subscription-site-manager.php +++ b/includes/integrations/woocommerce/class-iwp-woo-subscription-site-manager.php @@ -40,7 +40,8 @@ public static function get_customer_sites($customer_id) { )); foreach ($orders as $order) { - $order_sites = get_post_meta($order->get_id(), '_iwp_sites_created', true); + // HPOS-safe read — covers both data stores and legacy postmeta values. + $order_sites = IWP_Woo_HPOS::get_order_meta($order, '_iwp_sites_created'); if (!empty($order_sites) && is_array($order_sites)) { foreach ($order_sites as $site) { $sites[] = array_merge($site, array( @@ -98,10 +99,10 @@ public static function get_subscriptions_with_sites($args = array()) { public static function get_subscription_sites($subscription) { $sites = array(); - // Get sites from parent order + // Get sites from parent order — HPOS-safe read. $parent_order_id = $subscription->get_parent_id(); if ($parent_order_id) { - $parent_sites = get_post_meta($parent_order_id, '_iwp_sites_created', true); + $parent_sites = IWP_Woo_HPOS::get_order_meta($parent_order_id, '_iwp_sites_created'); if (!empty($parent_sites) && is_array($parent_sites)) { foreach ($parent_sites as $site) { $sites[] = array_merge($site, array( @@ -112,10 +113,10 @@ public static function get_subscription_sites($subscription) { } } - // Get sites from renewal orders + // Get sites from renewal orders — HPOS-safe read. $related_orders = $subscription->get_related_orders('all', 'renewal'); foreach ($related_orders as $order_id) { - $order_sites = get_post_meta($order_id, '_iwp_sites_created', true); + $order_sites = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_sites_created'); if (!empty($order_sites) && is_array($order_sites)) { foreach ($order_sites as $site) { $sites[] = array_merge($site, array( diff --git a/includes/integrations/woocommerce/class-iwp-woo-subscription-switch-handler.php b/includes/integrations/woocommerce/class-iwp-woo-subscription-switch-handler.php index f5021a8..b576745 100644 --- a/includes/integrations/woocommerce/class-iwp-woo-subscription-switch-handler.php +++ b/includes/integrations/woocommerce/class-iwp-woo-subscription-switch-handler.php @@ -165,9 +165,10 @@ public function handle_switch_completed($order) { $this->process_subscription_switch($order, $subscription, $results); } - // Mark as processed + // Mark as processed — meta-only save (no order-lifecycle hooks). + // $order is already loaded above; no need to re-fetch via the helper. $order->update_meta_data('_iwp_switch_processed', 'yes'); - $order->save(); + $order->save_meta_data(); IWP_Logger::info('Subscription switch processing complete', 'switch-handler', array( 'order_id' => $order->get_id(), @@ -428,12 +429,16 @@ private function process_plan_change($site_id, $old_plan_id, $new_plan_id, $subs 'plan_id' => $new_plan_id, ), ); - $existing_sites = get_post_meta($order->get_id(), '_iwp_sites_created', true); + // Read via the helper so legacy-postmeta values are picked up + // and migrated forward. Write stays on the in-scope $order — + // one meta-only save, no order-lifecycle hooks. + $existing_sites = IWP_Woo_HPOS::get_order_meta($order, '_iwp_sites_created'); if (!is_array($existing_sites)) { $existing_sites = array(); } $existing_sites[] = $fail_entry; - update_post_meta($order->get_id(), '_iwp_sites_created', $existing_sites); + $order->update_meta_data('_iwp_sites_created', $existing_sites); + $order->save_meta_data(); // Schedule retry $this->schedule_retry($site_id, $new_plan_id, $subscription->get_id(), $order->get_id()); @@ -480,16 +485,21 @@ private function process_plan_change($site_id, $old_plan_id, $new_plan_id, $subs 'plan_id' => $new_plan_id, ), ); - $existing_sites = get_post_meta($order->get_id(), '_iwp_sites_created', true); + // Read via the helper so legacy-postmeta values are picked up and + // migrated forward. Writes below stay on the $order instance — + // same object, same data store, one save. + $existing_sites = IWP_Woo_HPOS::get_order_meta($order, '_iwp_sites_created'); if (!is_array($existing_sites)) { $existing_sites = array(); } $existing_sites[] = $switch_site_entry; - update_post_meta($order->get_id(), '_iwp_sites_created', $existing_sites); - // Mark per-site as processed + // Batch both meta mutations onto the in-scope $order, then flush + // with a single meta-only save — no order-lifecycle hooks, and + // no redundant order-loading via IWP_Woo_HPOS::get_order(). + $order->update_meta_data('_iwp_sites_created', $existing_sites); $order->update_meta_data('_iwp_switch_processed_' . $site_id, 'yes'); - $order->save(); + $order->save_meta_data(); // Add success notes $note = sprintf( @@ -712,10 +722,10 @@ private function is_plan_upgrade($old_plan_id, $new_plan_id) { private function find_subscription_sites($subscription) { $sites = array(); - // Method 1: Get sites from parent order + // Method 1: Get sites from parent order — HPOS-safe read. $parent_order_id = $subscription->get_parent_id(); if ($parent_order_id) { - $parent_sites = get_post_meta($parent_order_id, '_iwp_sites_created', true); + $parent_sites = IWP_Woo_HPOS::get_order_meta($parent_order_id, '_iwp_sites_created'); if (!empty($parent_sites) && is_array($parent_sites)) { foreach ($parent_sites as $site) { // site_id may be at top level or nested inside site_data @@ -746,10 +756,10 @@ private function find_subscription_sites($subscription) { } } - // Method 3: Get sites from renewal orders + // Method 3: Get sites from renewal orders — HPOS-safe read. $related_orders = $subscription->get_related_orders('all', 'renewal'); foreach ($related_orders as $order_id) { - $order_sites = get_post_meta($order_id, '_iwp_sites_created', true); + $order_sites = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_sites_created'); if (!empty($order_sites) && is_array($order_sites)) { foreach ($order_sites as $site) { $site_id = $site['site_id'] ?? $site['id'] ?? ''; diff --git a/includes/integrations/woocommerce/class-iwp-woo-subscriptions-integration.php b/includes/integrations/woocommerce/class-iwp-woo-subscriptions-integration.php index 8df2de3..b927877 100644 --- a/includes/integrations/woocommerce/class-iwp-woo-subscriptions-integration.php +++ b/includes/integrations/woocommerce/class-iwp-woo-subscriptions-integration.php @@ -429,12 +429,13 @@ private function find_subscription_sites($subscription) { * @return array */ private function get_order_sites($order_id) { - $sites_created = get_post_meta($order_id, '_iwp_sites_created', true); - + // HPOS-safe read — covers both data stores and legacy postmeta values. + $sites_created = IWP_Woo_HPOS::get_order_meta($order_id, '_iwp_sites_created'); + if (empty($sites_created) || !is_array($sites_created)) { return array(); } - + return $sites_created; } diff --git a/iwp-wp-integration.php b/iwp-wp-integration.php index 5891708..904f605 100644 --- a/iwp-wp-integration.php +++ b/iwp-wp-integration.php @@ -3,7 +3,7 @@ * Plugin Name: InstaWP Integration * Plugin URI: https://instawp.com * Description: A comprehensive WordPress integration plugin for InstaWP that provides enhanced functionality, seamless integration, WooCommerce support, and standalone site creation tools. - * Version: 0.0.11 + * Version: 0.0.12 * Author: InstaWP * Author URI: https://instawp.com * Text Domain: iwp-wp-integration @@ -24,12 +24,12 @@ } // Define plugin constants -define('IWP_VERSION', '0.0.11'); +define('IWP_VERSION', '0.0.12'); define('IWP_PLUGIN_FILE', __FILE__); define('IWP_PLUGIN_PATH', plugin_dir_path(__FILE__)); define('IWP_PLUGIN_URL', plugin_dir_url(__FILE__)); define('IWP_PLUGIN_BASENAME', plugin_basename(__FILE__)); -define('IWP_PLUGIN_APP_URL', 'https://app.instawp.io'); +defined( 'IWP_PLUGIN_APP_URL' ) || define('IWP_PLUGIN_APP_URL', 'https://app.instawp.io'); // Include the autoloader require_once IWP_PLUGIN_PATH . 'includes/core/class-iwp-autoloader.php'; @@ -64,6 +64,16 @@ function iwp_init() { IWP_Installer::set_default_settings(); } + // Load the HPOS compat layer before anything that may call it. + // IWP_Admin_Simple, IWP_Frontend, IWP_Site_Manager and IWP_Database all + // route their order-meta reads/writes through IWP_Woo_HPOS, so its class + // must be declared before any of those classes instantiate or call into + // it. Methods resolve IWP_Woo_HPOS lazily at runtime today, but keeping + // the require_once here removes the dependency on load-order luck. + if (class_exists('WooCommerce')) { + require_once IWP_PLUGIN_PATH . 'includes/integrations/woocommerce/class-iwp-woo-hpos.php'; + } + // Initialize admin interface (admin only) if (is_admin()) { require_once IWP_PLUGIN_PATH . 'includes/admin/class-iwp-settings-page.php'; @@ -74,18 +84,17 @@ function iwp_init() { // Initialize simple admin new IWP_Admin_Simple(); - + // Initialize WooCommerce product integration (admin only) if (class_exists('WooCommerce')) { require_once IWP_PLUGIN_PATH . 'includes/integrations/woocommerce/class-iwp-woo-product-integration.php'; new IWP_Woo_Product_Integration(); } } - + // Initialize WooCommerce order processing (both admin and frontend) if (class_exists('WooCommerce')) { require_once IWP_PLUGIN_PATH . 'includes/integrations/woocommerce/class-iwp-woo-order-processor.php'; - require_once IWP_PLUGIN_PATH . 'includes/integrations/woocommerce/class-iwp-woo-hpos.php'; require_once IWP_PLUGIN_PATH . 'includes/integrations/woocommerce/class-iwp-woo-product-fields.php'; new IWP_Woo_Order_Processor();