From e3a47628ac9541069471cf52ea8cfdbe2c9cf74a Mon Sep 17 00:00:00 2001 From: dev5 Date: Wed, 6 May 2026 06:35:47 +0000 Subject: [PATCH 1/6] fix(sites-list): orphan delete + HPOS-aware order-meta query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orphan sites that lived only in WooCommerce order meta (left over from plugin builds before wp_iwp_sites existed) couldn't be deleted from the admin Sites list — they reappeared after every "delete" because the trash flow only updated the DB row and there was no row to update. The order-meta merge in the same list also ran a raw SQL join against wp_posts/wp_postmeta, which is HPOS-blind and missed every order under authoritative HPOS. Sites list / orphan delete: - IWP_Sites_Model::trash() now writes a deletion-marker row (status trashed, source order_meta) when no wp_iwp_sites row exists for the site_id, so the standard trashed-status filter hides the orphan from the default view (and shows it under the Trash tab). - IWP_Sites_List_Table::get_sites_from_orders() skips order-meta entries whose site_id is already represented in the DB-sourced list — covers both real sites and deletion markers. - ajax_delete_site() drops the now-unnecessary get_by_site_id guard around trash(); it's safe to call unconditionally. HPOS-aware order-meta read path: - New IWP_Woo_HPOS::get_orders_with_meta(array \$meta_keys) — single round trip in both data-store modes. Under CPT, the historic SQL shape. Under HPOS, EXISTS-style query against wc_orders + wc_orders_meta UNION ALL with wp_posts + wp_postmeta entries the active store doesn't already have (NOT EXISTS dedupe), so legacy postmeta values that haven't been forward-migrated yet remain visible. - get_sites_from_orders() now drives off this helper instead of its own raw SQL, fixing HPOS-only orders silently missing from the list. Performance stays at one indexed query — does NOT load every order into memory like get_orders([..., meta_query]) would. UX polish: - Working Show/Hide toggle on the Sites list password column (new assets/js/sites-list.js + admin enqueue + revealed-span markup). - Friendlier "subdomain already taken" error in both shortcode and WooCommerce checkout — rewrites upstream "X site name is not available." into "The subdomain \"X\" is already taken. Please choose a different one." - API client surfaces upstream error messages directly instead of prefixing with "API request failed with status code N: ". - New IWP_Woo_Product_Fields::field_label() — single source of truth for the customer-facing "Subdomain" label. Docs: - CHANGELOG.md and README.md under the existing [Unreleased] section. - New CLAUDE.md "HPOS Compatibility" subsection: read-helper picker table (get_order_meta vs get_orders vs get_orders_with_meta), the performance trade-off, and the writes-must-go-through-helpers rule. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 10 ++ CLAUDE.md | 22 +++++ README.md | 17 ++++ assets/js/sites-list.js | 30 ++++++ includes/admin/class-iwp-admin-simple.php | 28 +++++- includes/admin/class-iwp-admin.php | 12 +-- includes/admin/class-iwp-sites-list-table.php | 53 ++++++----- includes/core/class-iwp-api-client.php | 25 +++-- includes/core/class-iwp-shortcode.php | 10 +- includes/core/class-iwp-sites-model.php | 12 ++- .../woocommerce/class-iwp-woo-hpos.php | 95 +++++++++++++++++++ .../class-iwp-woo-order-processor.php | 11 +++ .../class-iwp-woo-product-fields.php | 15 ++- 13 files changed, 296 insertions(+), 44 deletions(-) create mode 100644 assets/js/sites-list.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ae906e..834041a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Working Show/Hide toggle on the Sites list password column.** The masked password column now has a functional reveal button that toggles between hidden and visible inline values. + ### Changed - **Full compatibility with WooCommerce High-Performance Order Storage (HPOS).** - With HPOS enabled, newly created site records now store and read correctly across all flows — checkout auto-create, manual site creation from the order screen, subscription plan switches, deferred onboarding, and the customer My Account dashboard. @@ -14,6 +17,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The My Account "Your Sites" panel now lists every site the customer owns regardless of which data store WooCommerce is configured to use. - The admin "Sites created" statistic reflects real data under HPOS instead of returning zero. - Standard debug-log notices previously emitted by WooCommerce when the plugin issued order queries under HPOS are gone. +- New `IWP_Woo_Product_Fields::field_label()` static — single source of truth for the customer-facing **Subdomain** label, ready for reuse in new code that mentions the field by name (cart/checkout review, error messages). Existing strings that embed the label inside a full sentence are left as-is so existing translations remain valid. + +### Fixed +- **Orphan sites can now be deleted from the admin Sites list.** Sites that lived only in WooCommerce order meta — typically left over from older plugin versions before `wp_iwp_sites` existed — used to silently reappear after delete because the trash flow only updated the DB row, and there was no DB row to update. Delete now records a deletion marker in `wp_iwp_sites`, and the Sites list skips any order-meta entry whose `site_id` is already represented in the DB so the standard trashed-status filter hides it from the default view (and shows it under the **Trash** tab). +- **Sites list page now sees orders created under HPOS.** The order-meta merge in the admin Sites list ran a raw SQL join against `wp_posts` + `wp_postmeta` filtered by `post_type='shop_order'`, which under authoritative HPOS misses every order (orders live in `wp_wc_orders`, meta in `wp_wc_orders_meta`). Replaced with a new `IWP_Woo_HPOS::get_orders_with_meta()` helper that queries the active store and UNIONs in any legacy `wp_postmeta` values that haven't been forward-migrated yet — single round trip in both modes. The orphan-delete fix above now works correctly on HPOS-authoritative stores. +- **Friendlier "subdomain already taken" error message.** Previously surfaced to customers as `API request failed with status code 422: john-doe site name is not available.` It now reads `The subdomain "john-doe" is already taken. Please choose a different one.` on both the `[iwp_site_creator]` shortcode form and the WooCommerce checkout flow. +- **API error messages no longer get prefixed with `API request failed with status code N: `.** The upstream API's human-readable message is used directly when present; the generic status-code text is kept only as a fallback when the response body has no message. ## [0.0.11] - 2026-04-21 diff --git a/CLAUDE.md b/CLAUDE.md index c584079..bc922c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -690,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..3375fde 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,23 @@ This plugin is licensed under the GPL v2 or later. ## Changelog +### Unreleased +- **NEW: Show/Hide toggle works on the Sites list Password column** — masked password reveals inline when clicked +- **FIXED: Orphan sites (in order meta only) can now be deleted from the admin Sites list** — previously they silently reappeared after delete; now hidden via the standard Trash filter and visible under the **Trash** tab +- **FIXED: Sites list page now sees orders created under HPOS** — the order-meta merge previously ran raw SQL against `wp_posts`/`wp_postmeta`, missing every order under authoritative HPOS; replaced with a new HPOS-aware single-query helper that also UNIONs in legacy postmeta values for full coverage on stores mid-migration +- **FIXED: Friendlier "subdomain already taken" error** — replaces the raw `API request failed with status code 422: ... site name is not available.` text on both the shortcode form and WooCommerce checkout +- **FIXED: API error messages no longer prefixed with `API request failed with status code N:`** — upstream message surfaces directly when present +- **CHANGED: Full compatibility with WooCommerce High-Performance Order Storage (HPOS)** — site records now store and read correctly across all flows under HPOS; older orders are migrated forward on first read; My Account "Your Sites" panel and admin "Sites created" statistic both work regardless of data store + +### 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 7ebfa1a..c4ed2d3 100644 --- a/includes/admin/class-iwp-admin-simple.php +++ b/includes/admin/class-iwp-admin-simple.php @@ -597,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($) { @@ -651,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; } '); } diff --git a/includes/admin/class-iwp-admin.php b/includes/admin/class-iwp-admin.php index bb6925c..4bc9f7d 100644 --- a/includes/admin/class-iwp-admin.php +++ b/includes/admin/class-iwp-admin.php @@ -2270,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..306fdd7 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) @@ -516,40 +519,44 @@ 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 = array_column($db_sites, 'site_id'); + + // 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 (!empty($site['site_id']) && in_array($site['site_id'], $db_site_ids, true)) { + continue; + } + $sites[] = $site; } } @@ -908,9 +915,9 @@ public function column_password($item) { if (empty($item['password'])) { return '—'; } - + return sprintf( - '•••••••• ', + '•••••••• ', esc_attr($item['password']), __('Show', 'iwp-wp-integration') ); diff --git a/includes/core/class-iwp-api-client.php b/includes/core/class-iwp-api-client.php index 0030b50..4f59c3c 100644 --- a/includes/core/class-iwp-api-client.php +++ b/includes/core/class-iwp-api-client.php @@ -149,18 +149,23 @@ 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)); + + IWP_Logger::error('API request failed', 'api-client', array( + 'status_code' => $response_code, + 'error' => $error_message, + )); return new WP_Error('api_request_failed', $error_message); } diff --git a/includes/core/class-iwp-shortcode.php b/includes/core/class-iwp-shortcode.php index c250ea9..00d0610 100644 --- a/includes/core/class-iwp-shortcode.php +++ b/includes/core/class-iwp-shortcode.php @@ -204,7 +204,15 @@ 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())); + $error_message = $result->get_error_message(); + if (preg_match('/^(.+?) site name is not available\.?$/i', $error_message, $m)) { + $error_message = sprintf( + /* translators: %s is the site name the customer tried to use. */ + __('The site name "%s" is already taken. Please choose a different one.', 'iwp-wp-integration'), + $m[1] + ); + } + wp_send_json_error(array('message' => $error_message)); } // Check if site was created successfully diff --git a/includes/core/class-iwp-sites-model.php b/includes/core/class-iwp-sites-model.php index 4e8f0d2..5c70372 100644 --- a/includes/core/class-iwp-sites-model.php +++ b/includes/core/class-iwp-sites-model.php @@ -183,7 +183,17 @@ 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 so + // the listing layer's trashed-status filter can hide it. + return (bool) self::create(array( + 'site_id' => $site_id, + 'status' => self::STATUS_TRASHED, + 'source' => 'order_meta', + )); } /** diff --git a/includes/integrations/woocommerce/class-iwp-woo-hpos.php b/includes/integrations/woocommerce/class-iwp-woo-hpos.php index a67b33b..39d3730 100644 --- a/includes/integrations/woocommerce/class-iwp-woo-hpos.php +++ b/includes/integrations/woocommerce/class-iwp-woo-hpos.php @@ -260,6 +260,101 @@ public static function get_orders($args = array()) { 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); + } + /** * Get order count (HPOS compatible) * diff --git a/includes/integrations/woocommerce/class-iwp-woo-order-processor.php b/includes/integrations/woocommerce/class-iwp-woo-order-processor.php index bc7608a..fd70d2a 100644 --- a/includes/integrations/woocommerce/class-iwp-woo-order-processor.php +++ b/includes/integrations/woocommerce/class-iwp-woo-order-processor.php @@ -396,6 +396,17 @@ private function create_site_for_product($order, $product, $snapshot_slug, $item ); if (is_wp_error($result)) { + $error_message = $result->get_error_message(); + if (preg_match('/^(.+?) site name is not available\.?$/i', $error_message, $m)) { + $result = new WP_Error( + $result->get_error_code(), + sprintf( + /* translators: %s is the subdomain the customer tried to use. */ + __('The subdomain "%s" is already taken. Please choose a different one.', 'iwp-wp-integration'), + $m[1] + ) + ); + } return $result; } 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']), ); } From ab1472ea85c02bfb35d797039f810bba4f65abbd Mon Sep 17 00:00:00 2001 From: dev5 Date: Wed, 6 May 2026 06:50:12 +0000 Subject: [PATCH 2/6] fix(sites-list): short-circuit dedupe check when no DB sites Guard array_column against an empty $db_sites and skip the per-iteration in_array() when the dedupe set is empty. No behavior change; saves a small per-row cost on stores where get_sites_from_database() returns no rows (e.g. fresh installs surfacing only legacy order-meta sites). Co-Authored-By: Claude Opus 4.7 (1M context) --- includes/admin/class-iwp-sites-list-table.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/includes/admin/class-iwp-sites-list-table.php b/includes/admin/class-iwp-sites-list-table.php index 306fdd7..07432da 100644 --- a/includes/admin/class-iwp-sites-list-table.php +++ b/includes/admin/class-iwp-sites-list-table.php @@ -526,7 +526,8 @@ private function get_sites_from_orders($db_sites = array()) { // 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 = array_column($db_sites, 'site_id'); + $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 @@ -553,7 +554,7 @@ private function get_sites_from_orders($db_sites = array()) { if (!$site) { continue; } - if (!empty($site['site_id']) && in_array($site['site_id'], $db_site_ids, true)) { + if ( $has_site_ids && !empty($site['site_id']) && in_array($site['site_id'], $db_site_ids, true) ) { continue; } $sites[] = $site; From 39dd8f536970c44b90df6549cab82fbae380e32b Mon Sep 17 00:00:00 2001 From: dev5 Date: Wed, 6 May 2026 11:49:09 +0000 Subject: [PATCH 3/6] fix(sites-model): populate orphan tombstone with order-meta details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression on top of e3a4762: orphan tombstones were created with only {site_id, status=trashed, source=order_meta}, leaving every other column empty. Result: the Trash filter view at admin.php?page=instawp-sites&status=trashed showed empty rows for every orphan delete — no URL, no credentials, no order/customer reference. trash() now merges in metadata recovered from order meta before creating the tombstone. New private collect_orphan_metadata($site_id) helper: - Same data path as IWP_Sites_List_Table::get_sites_from_orders() (IWP_Woo_HPOS::get_orders_with_meta + dual-format unwrap), so it's guaranteed to find anything the admin list could see. - Maps order-meta fields onto wp_iwp_sites columns: site_url (← wp_url), wp_admin_url (or derived from site_url), wp_username, wp_password, s_hash, plan_id, snapshot_slug, task_id, created_at, order_id, product_id, user_id (from WC_Order::get_customer_id). - source_data carries a forensic snapshot of the full original entry so context survives once the order meta is later cleaned up. - Last-resort fallback: when the lookup matches nothing, set both site_url and wp_admin_url to IWP_PLUGIN_APP_URL/sites/{site_id} so the Trash row is always actionable (clickable InstaWP dashboard link). Co-Authored-By: Claude Opus 4.7 (1M context) --- includes/core/class-iwp-sites-model.php | 154 ++++++++++++++++++++++-- 1 file changed, 147 insertions(+), 7 deletions(-) diff --git a/includes/core/class-iwp-sites-model.php b/includes/core/class-iwp-sites-model.php index 5c70372..d1ca426 100644 --- a/includes/core/class-iwp-sites-model.php +++ b/includes/core/class-iwp-sites-model.php @@ -187,13 +187,153 @@ public static function trash($site_id, $api_client = null) { return self::update($site_id, array('status' => self::STATUS_TRASHED)); } - // Orphan: site lives only in order meta. Record a deletion marker so - // the listing layer's trashed-status filter can hide it. - return (bool) self::create(array( - 'site_id' => $site_id, - 'status' => self::STATUS_TRASHED, - 'source' => 'order_meta', - )); + // 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; } /** From add814fd53804db08ff9f5dbef7fc2feee80d24e Mon Sep 17 00:00:00 2001 From: dev5 Date: Wed, 6 May 2026 12:27:19 +0000 Subject: [PATCH 4/6] release: bump version to 0.0.12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IWP_VERSION constant + plugin header → 0.0.12 - CHANGELOG [Unreleased] → [0.0.12] - 2026-05-07 - README Unreleased → Version 0.0.12 Cuts the in-flight site-delete-list work (orphan delete, HPOS-aware order-meta query, tombstone metadata recovery) into a tagged release. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- README.md | 2 +- iwp-wp-integration.php | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 834041a..9e4d12e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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). -## [Unreleased] +## [0.0.12] - 2026-05-07 ### Added - **Working Show/Hide toggle on the Sites list password column.** The masked password column now has a functional reveal button that toggles between hidden and visible inline values. diff --git a/README.md b/README.md index 3375fde..000aa73 100644 --- a/README.md +++ b/README.md @@ -356,7 +356,7 @@ This plugin is licensed under the GPL v2 or later. ## Changelog -### Unreleased +### Version 0.0.12 - **NEW: Show/Hide toggle works on the Sites list Password column** — masked password reveals inline when clicked - **FIXED: Orphan sites (in order meta only) can now be deleted from the admin Sites list** — previously they silently reappeared after delete; now hidden via the standard Trash filter and visible under the **Trash** tab - **FIXED: Sites list page now sees orders created under HPOS** — the order-meta merge previously ran raw SQL against `wp_posts`/`wp_postmeta`, missing every order under authoritative HPOS; replaced with a new HPOS-aware single-query helper that also UNIONs in legacy postmeta values for full coverage on stores mid-migration diff --git a/iwp-wp-integration.php b/iwp-wp-integration.php index 7d37708..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,7 +24,7 @@ } // 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__)); From ab29aee4de49eacfea75ee69c5df075979bce16a Mon Sep 17 00:00:00 2001 From: dev5 Date: Wed, 6 May 2026 12:57:57 +0000 Subject: [PATCH 5/6] refactor(api-client): centralize humanize_error + add to onboarding flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "X site name is not available." → "The site name "X" is already taken." rewrite was inlined twice (shortcode + WooCommerce checkout order processor) and missing from the deferred-onboarding AJAX path, where customers were still seeing the raw upstream API text. - IWP_API_Client::humanize_error($error) — new static, accepts WP_Error or string, returns the customer-friendly version. Single source of truth for InstaWP API error rewrites; future patterns extend here instead of re-spreading regexes across call sites. - class-iwp-shortcode.php — replaces 8-line inline rewrite with one helper call. Wording unchanged ("site name"). - class-iwp-woo-order-processor.php — replaces 11-line inline rewrite with helper. Constructs a new WP_Error only when the helper actually rewrote the message (string-equality check), preserving the original WP_Error pass-through for unrelated failures. Note: WooCommerce checkout flow now uses "site name" terminology (was "subdomain") to stay consistent with the shortcode flow's wording. - class-iwp-onboarding.php — adds the helper at the surface where the user reported "new-test-5 site name is not available." was leaking through (line 323 in the deferred site creation AJAX response). Co-Authored-By: Claude Opus 4.7 (1M context) --- includes/core/class-iwp-api-client.php | 29 +++++++++++++++++++ includes/core/class-iwp-shortcode.php | 10 +------ includes/frontend/class-iwp-onboarding.php | 2 +- .../class-iwp-woo-order-processor.php | 13 ++------- 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/includes/core/class-iwp-api-client.php b/includes/core/class-iwp-api-client.php index 4f59c3c..f9a86ea 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 name "%s" is already taken. Please choose a different one.', 'iwp-wp-integration'), + $m[1] + ); + } + + return $message; + } + /** * Set API key * diff --git a/includes/core/class-iwp-shortcode.php b/includes/core/class-iwp-shortcode.php index 00d0610..241edc6 100644 --- a/includes/core/class-iwp-shortcode.php +++ b/includes/core/class-iwp-shortcode.php @@ -204,15 +204,7 @@ public function handle_ajax_site_creation() { $result = $api_client->create_site_from_snapshot($snapshot_slug, $site_data); if (is_wp_error($result)) { - $error_message = $result->get_error_message(); - if (preg_match('/^(.+?) site name is not available\.?$/i', $error_message, $m)) { - $error_message = sprintf( - /* translators: %s is the site name the customer tried to use. */ - __('The site name "%s" is already taken. Please choose a different one.', 'iwp-wp-integration'), - $m[1] - ); - } - wp_send_json_error(array('message' => $error_message)); + wp_send_json_error(array('message' => IWP_API_Client::humanize_error($result))); } // Check if site was created successfully diff --git a/includes/frontend/class-iwp-onboarding.php b/includes/frontend/class-iwp-onboarding.php index 98d4ff6..2b632e1 100644 --- a/includes/frontend/class-iwp-onboarding.php +++ b/includes/frontend/class-iwp-onboarding.php @@ -320,7 +320,7 @@ 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 — HPOS-safe read/write. diff --git a/includes/integrations/woocommerce/class-iwp-woo-order-processor.php b/includes/integrations/woocommerce/class-iwp-woo-order-processor.php index fd70d2a..a714fde 100644 --- a/includes/integrations/woocommerce/class-iwp-woo-order-processor.php +++ b/includes/integrations/woocommerce/class-iwp-woo-order-processor.php @@ -396,16 +396,9 @@ private function create_site_for_product($order, $product, $snapshot_slug, $item ); if (is_wp_error($result)) { - $error_message = $result->get_error_message(); - if (preg_match('/^(.+?) site name is not available\.?$/i', $error_message, $m)) { - $result = new WP_Error( - $result->get_error_code(), - sprintf( - /* translators: %s is the subdomain the customer tried to use. */ - __('The subdomain "%s" is already taken. Please choose a different one.', 'iwp-wp-integration'), - $m[1] - ) - ); + $humanized = IWP_API_Client::humanize_error($result); + if ($humanized !== $result->get_error_message()) { + $result = new WP_Error($result->get_error_code(), $humanized); } return $result; } From d3edd70f859ecdf74d27bfa697e1e859fd246fcd Mon Sep 17 00:00:00 2001 From: dev5 Date: Wed, 6 May 2026 13:01:05 +0000 Subject: [PATCH 6/6] fix(api-client): humanize error at the source in make_request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply IWP_API_Client::humanize_error() at the earliest point in the error chain — when make_request constructs the WP_Error from a non-2xx response. Every downstream consumer (shortcode AJAX, WooCommerce checkout, deferred onboarding, subscription switch) now surfaces customer-friendly text by default without each having to know the rewrite rules. Logging path keeps the RAW upstream message so support/debug context still captures exactly what the InstaWP API said. The downstream helper calls added in ab29aee become defensive pass-throughs (no-op when the message is already humanized) — kept for safety against any future error source that bypasses make_request. Co-Authored-By: Claude Opus 4.7 (1M context) --- includes/core/class-iwp-api-client.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/includes/core/class-iwp-api-client.php b/includes/core/class-iwp-api-client.php index f9a86ea..850a5e3 100644 --- a/includes/core/class-iwp-api-client.php +++ b/includes/core/class-iwp-api-client.php @@ -191,11 +191,18 @@ private function make_request($endpoint, $args = array()) { ); } + // 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 new WP_Error('api_request_failed', $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);