Conversation
Adds a new demo expiry behavior where the site persists indefinitely with the frontend blocked until the customer explicitly activates it, as an alternative to the existing auto-delete-after-time behavior. - Product: new demo_behavior meta (delete_after_time | keep_until_live) with get/set methods and is_keep_until_live() helper - Site: is_keep_until_live() delegates to the plan product's behavior - lock_site(): blocks frontend for keep-until-live demo sites; site admins and super admins are allowed through to preview their work - handle_site_published(): skips expiration timer for keep-until-live - Cron checks: skip keep-until-live sites (no auto-deletion/warnings) - add_demo_admin_bar_menu(): shows "Demo Mode / Go Live" in admin bar on the frontend for site admins - handle_go_live_action(): nonce-protected ?wu_go_live=ID handler for instant activation when no external Go Live URL is configured - convert_demo_to_live(): converts DEMO → CUSTOMER_OWNED, clears meta, fires wu_before/after_demo_site_converted hooks - Settings: new demo_go_live_url field to configure the checkout URL customers are sent to when they click "Go Live" - Product edit page: new "Demo Settings" section (visible for demo products) with the Demo Expiry Behavior select field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR introduces comprehensive demo site functionality, including new product and site types, automated lifecycle management (expiration/deletion), customer notifications, product settings UI, and conversion flows to promote demo sites to live accounts. Changes
Sequence DiagramssequenceDiagram
actor Customer
participant Checkout as Checkout<br/>Handler
participant DB as Database
participant SiteMgr as Site<br/>Manager
Customer->>Checkout: Purchase Demo Product
Checkout->>DB: Create Site with type=DEMO
Checkout->>DB: Store Plan (demo behavior)
DB-->>SiteMgr: Site Published
SiteMgr->>DB: Calculate expiration timestamp<br/>(if delete_after_time)
SiteMgr->>DB: Store demo_expires_at meta
SiteMgr->>DB: Log demo site creation
sequenceDiagram
participant Scheduler as WP Scheduler
participant SiteMgr as Site<br/>Manager
participant DB as Database
participant EmailMgr as Email<br/>Manager
participant Customer as Customer
participant Admin as Admin
loop Hourly
Scheduler->>SiteMgr: check_expired_demo_sites()
SiteMgr->>DB: Query expired demos
alt Expired Found
SiteMgr->>SiteMgr: async_delete_demo_site()
SiteMgr->>DB: Delete site/membership
SiteMgr->>DB: Log deletion
end
end
loop Hourly
Scheduler->>SiteMgr: check_expiring_demo_sites()
SiteMgr->>DB: Query expiring demos<br/>(within warning window)
alt Expiring & Not Notified
SiteMgr->>EmailMgr: Dispatch demo_site_expiring event
EmailMgr->>Customer: Send expiration notification
EmailMgr->>Admin: Send admin alert
SiteMgr->>DB: Mark notified=true
end
end
sequenceDiagram
actor Admin as Admin User
participant AdminBar as Admin Bar
participant Frontend as Frontend<br/>Handler
participant SiteMgr as Site<br/>Manager
participant DB as Database
Admin->>AdminBar: View keep-until-live demo site
AdminBar-->>Admin: Show "Demo Mode" badge + Go Live link
Admin->>Frontend: Click "Go Live" action
Frontend->>Frontend: Validate nonce & permissions
Frontend->>SiteMgr: convert_demo_to_live(site_id)
SiteMgr->>DB: Clear demo metadata
SiteMgr->>DB: Set type=CUSTOMER_OWNED
SiteMgr->>DB: Fire post_convert hooks
SiteMgr-->>Frontend: Return true
Frontend-->>Admin: Redirect to home
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
There was a problem hiding this comment.
Actionable comments posted: 9
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@inc/class-settings.php`:
- Around line 1431-1455: The UI defaults for demo_expiring_notification and
demo_expiring_warning_time conflict with runtime expectations in
check_expiring_demo_sites(); add these keys to Settings::get_setting_defaults()
setting demo_expiring_notification => false and demo_expiring_warning_time =>
24, and update the runtime consumer: when retrieving demo_expiring_warning_time
in check_expiring_demo_sites() convert the value to hours using
demo_duration_unit (or alternatively rename the UI field to explicitly state
“hours”) so the UI, defaults, and runtime all agree; reference
add_field('sites','demo_expiring_notification'),
add_field('sites','demo_expiring_warning_time'),
Settings::get_setting_defaults(), check_expiring_demo_sites(), and
demo_duration_unit to locate the changes.
- Around line 1389-1402: Site demo duration of 0 is currently treated as "now"
because Site::calculate_demo_expiration multiplies the duration, so fix by
making calculate_demo_expiration treat a configured duration of 0 as "no
expiration" (return null / no timestamp) instead of returning now + 0; also
update the add_field call for 'sites','demo_duration' (title/desc/default/min)
to either remove the suggestion that 0 disables deletion or set min to 1 if you
prefer to forbid 0 in the UI so the behavior and UI are consistent.
In `@inc/functions/product.php`:
- Around line 192-199: The plan-query helpers (wu_get_plans() and
wu_get_plans_as_options()) are still only querying type = 'plan' while
wu_is_plan_type() and cart validation treat 'demo' as a plan; update these
helpers to include 'demo' (e.g., apply the same $plan_types filter used earlier:
wu_plan_product_types/wu_plan_type) so their queries return both 'plan' and
'demo', or alternatively create a new helper (e.g., wu_get_plan_like_products()
used by wu_get_plans_as_options() and callers such as
Product_Edit_Admin_Page::get_product_option_sections()) that queries all types
returned by wu_is_plan_type()/the $plan_types filter and switch callers to use
it.
In `@inc/managers/class-email-manager.php`:
- Around line 536-560: The new demo_site_expiring system emails are only
registered via register_default_system_email but won't be seeded into existing
installs because send_system_email only backfills when the emails table is
empty; update the registration flow to ensure missing defaults are created for
upgrades by checking for the existence of each slug (e.g.,
'demo_site_expiring_customer' and 'demo_site_expiring_admin') during
registration (in the code that calls register_default_system_email) and insert
the default record if missing, or add a migration/setup routine that runs once
on plugin upgrade to call the same insert logic for any newly introduced events
(reference demo_site_expiring, register_default_system_email and
send_system_email to locate the relevant logic).
In `@inc/managers/class-site-manager.php`:
- Around line 1337-1341: The current check treats any truthy value from
$site->save() as success; change the guard around the call to $site->save() (the
$saved variable) to treat only a strict boolean true as success (e.g., if
($saved !== true) return new \WP_Error(...)); ensure this early return prevents
the success logging and the wu_after_demo_site_converted hook from running when
$site->save() does not return true.
- Around line 1071-1082: The payload passed to wu_do_event('demo_site_expiring')
is only nested arrays so the flat template tokens aren't available; update the
$payload built in class-site-manager (the $payload variable before wu_do_event)
to include flattened keys used by the email templates such as site_title (from
$site->get_title() or $site->to_array()['title']), customer_name (from
$customer->get_name() or $customer->to_array()['name']), site_manage_url
(get_admin_url($site->get_blog_id())), customer_manage_url
(get_admin_url($customer->get_blog_id()) or appropriate customer admin URL),
plus any computed tokens like site_url, site_admin_url, demo_expires_at and
demo_time_remaining so templates referencing {{site_title}}, {{customer_name}},
{{site_manage_url}}, {{customer_manage_url}} will be present when
wu_do_event('demo_site_expiring') is fired.
- Around line 271-294: The code sets demo_expires_at for every demo site that
isn't keep-until-live, which wrongly includes cases where automatic deletion is
disabled (demo_duration == 0); wrap the calculate_demo_expiration() /
set_demo_expires_at() calls in an explicit check that automatic demo deletion is
enabled (i.e. demo duration > 0) before computing or saving an expiration; in
practice, add a conditional before calling $site->calculate_demo_expiration()
and $site->set_demo_expires_at($expires_at) (use your settings getter or the
existing settings contract to test demo_duration != 0) so freshly created demos
aren’t marked expired when auto-deletion is disabled.
- Around line 1122-1159: The site delete flow must only run cleanup if the
actual deletion succeeded: call wpmu_delete_blog($blog_id, true) (when $blog_id)
and capture its return; call $site->delete() and capture its $result, then only
proceed to the membership/customer/user cleanup (use $membership->get_sites(),
$membership->delete(), $customer->get_memberships(), $customer->delete(),
wpmu_delete_user()) when the deletion steps returned success; if either deletion
fails, stop further deletes and surface/log the failure instead of orphaning
records.
In `@views/emails/admin/demo-site-expiring.php`:
- Around line 86-88: The table label currently uses esc_html_e('Admin Panel',
'ultimate-multisite') but the link points to {{customer_manage_url}} with the
CTA esc_html_e('Go to Customer', 'ultimate-multisite'); update the label to
match the destination/CTA (for example esc_html_e('Customer',
'ultimate-multisite') or esc_html_e('Manage Customer', 'ultimate-multisite')) so
the row consistently describes the link; modify the string inside the first <td>
that contains esc_html_e('Admin Panel', 'ultimate-multisite') accordingly.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 6e31a639-08a4-4e08-861b-55dedfa656aa
📒 Files selected for processing (14)
inc/admin-pages/class-product-edit-admin-page.phpinc/checkout/class-checkout.phpinc/class-settings.phpinc/database/products/class-product-type.phpinc/database/sites/class-site-type.phpinc/functions/product.phpinc/list-tables/class-site-list-table.phpinc/managers/class-email-manager.phpinc/managers/class-event-manager.phpinc/managers/class-site-manager.phpinc/models/class-product.phpinc/models/class-site.phpviews/emails/admin/demo-site-expiring.phpviews/emails/customer/demo-site-expiring.php
| $this->add_field( | ||
| 'sites', | ||
| 'demo_duration', | ||
| [ | ||
| 'title' => __('Demo Duration', 'ultimate-multisite'), | ||
| 'desc' => __('How long demo sites should remain active before being automatically deleted. Set to 0 to disable automatic deletion.', 'ultimate-multisite'), | ||
| 'type' => 'number', | ||
| 'default' => 2, | ||
| 'min' => 0, | ||
| 'html_attr' => [ | ||
| 'style' => 'width: 80px;', | ||
| ], | ||
| ] | ||
| ); |
There was a problem hiding this comment.
0 currently means “expire immediately”, not “disable”.
This field advertises 0 as “disable automatic deletion”, but Site::calculate_demo_expiration() multiplies the configured duration and returns now + 0, so new demo sites become immediately eligible for cleanup. Either handle 0 as “no expiration” downstream or remove 0 from the allowed UI here.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@inc/class-settings.php` around lines 1389 - 1402, Site demo duration of 0 is
currently treated as "now" because Site::calculate_demo_expiration multiplies
the duration, so fix by making calculate_demo_expiration treat a configured
duration of 0 as "no expiration" (return null / no timestamp) instead of
returning now + 0; also update the add_field call for 'sites','demo_duration'
(title/desc/default/min) to either remove the suggestion that 0 disables
deletion or set min to 1 if you prefer to forbid 0 in the UI so the behavior and
UI are consistent.
| $this->add_field( | ||
| 'sites', | ||
| 'demo_expiring_notification', | ||
| [ | ||
| 'title' => __('Send Expiration Warning Email', 'ultimate-multisite'), | ||
| 'desc' => __('When enabled, customers will receive an email notification before their demo site expires.', 'ultimate-multisite'), | ||
| 'type' => 'toggle', | ||
| 'default' => 1, | ||
| ] | ||
| ); | ||
|
|
||
| $this->add_field( | ||
| 'sites', | ||
| 'demo_expiring_warning_time', | ||
| [ | ||
| 'title' => __('Warning Time Before Expiration', 'ultimate-multisite'), | ||
| 'desc' => __('How long before the demo expires should the warning email be sent. Uses the same time unit as demo duration.', 'ultimate-multisite'), | ||
| 'type' => 'number', | ||
| 'default' => 1, | ||
| 'min' => 1, | ||
| 'html_attr' => [ | ||
| 'style' => 'width: 80px;', | ||
| ], | ||
| ] | ||
| ); |
There was a problem hiding this comment.
Align the warning settings with the runtime contract.
check_expiring_demo_sites() reads demo_expiring_notification with a runtime default of false, and it interprets demo_expiring_warning_time as hours with a default of 24. Here the UI defaults are enabled/1, and the description says the warning uses the same unit as demo_duration. On a fresh install, the behavior will not match what this screen advertises. Please add these keys to Settings::get_setting_defaults() and either rename this field to hours or convert it using demo_duration_unit.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@inc/class-settings.php` around lines 1431 - 1455, The UI defaults for
demo_expiring_notification and demo_expiring_warning_time conflict with runtime
expectations in check_expiring_demo_sites(); add these keys to
Settings::get_setting_defaults() setting demo_expiring_notification => false and
demo_expiring_warning_time => 24, and update the runtime consumer: when
retrieving demo_expiring_warning_time in check_expiring_demo_sites() convert the
value to hours using demo_duration_unit (or alternatively rename the UI field to
explicitly state “hours”) so the UI, defaults, and runtime all agree; reference
add_field('sites','demo_expiring_notification'),
add_field('sites','demo_expiring_warning_time'),
Settings::get_setting_defaults(), check_expiring_demo_sites(), and
demo_duration_unit to locate the changes.
| * Demo products are included by default as they function like plans | ||
| * but create sites with automatic expiration. | ||
| * | ||
| * @since 2.3.0 | ||
| * @param array $plan_types Array of product types to treat as plans. | ||
| * @return array | ||
| */ | ||
| $plan_types = apply_filters('wu_plan_product_types', ['plan']); | ||
| $plan_types = apply_filters('wu_plan_product_types', ['plan', 'demo']); |
There was a problem hiding this comment.
Keep plan-like helpers consistent with wu_is_plan_type().
demo is now treated as a plan in cart validation and product segregation, but wu_get_plans() / wu_get_plans_as_options() in this same file still only return type = 'plan'. That lets other UIs still treat demo products as add-ons — for example, Product_Edit_Admin_Page::get_product_option_sections() excludes only wu_get_plans_as_options() from available_addons — and checkout later rejects those combinations as containing multiple plans. Please either include demo in the plan-query helpers or introduce a separate “plan-like” helper and switch callers to it.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@inc/functions/product.php` around lines 192 - 199, The plan-query helpers
(wu_get_plans() and wu_get_plans_as_options()) are still only querying type =
'plan' while wu_is_plan_type() and cart validation treat 'demo' as a plan;
update these helpers to include 'demo' (e.g., apply the same $plan_types filter
used earlier: wu_plan_product_types/wu_plan_type) so their queries return both
'plan' and 'demo', or alternatively create a new helper (e.g.,
wu_get_plan_like_products() used by wu_get_plans_as_options() and callers such
as Product_Edit_Admin_Page::get_product_option_sections()) that queries all
types returned by wu_is_plan_type()/the $plan_types filter and switch callers to
use it.
| /* | ||
| * Demo Site Expiring - Customer | ||
| */ | ||
| $this->register_default_system_email( | ||
| [ | ||
| 'event' => 'demo_site_expiring', | ||
| 'slug' => 'demo_site_expiring_customer', | ||
| 'target' => 'customer', | ||
| 'title' => __('Your demo site is about to expire', 'ultimate-multisite'), | ||
| 'content' => wu_get_template_contents('emails/customer/demo-site-expiring'), | ||
| ] | ||
| ); | ||
|
|
||
| /* | ||
| * Demo Site Expiring - Admin | ||
| */ | ||
| $this->register_default_system_email( | ||
| [ | ||
| 'event' => 'demo_site_expiring', | ||
| 'slug' => 'demo_site_expiring_admin', | ||
| 'target' => 'admin', | ||
| 'title' => __('A demo site is about to expire', 'ultimate-multisite'), | ||
| 'content' => wu_get_template_contents('emails/admin/demo-site-expiring'), | ||
| ] | ||
| ); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n -C3 "demo_site_expiring_(customer|admin)|create_all_system_emails|create_system_email\(|register_all_default_system_emails|wu_get_default_system_emails" .Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 13555
🏁 Script executed:
#!/bin/bash
# Search for where migrator is called and upgrade hooks
rg -n "create_all_system_emails|_install_create_emails|activation|upgrade|migrat" inc/installers/ inc/managers/ --type php | grep -E "(do_action|apply_filters|add_action|register|hook|activate|upgrade)" | head -40Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 1659
🏁 Script executed:
#!/bin/bash
# Look for activation and upgrade hooks
rg -n "register_activation_hook|wp_version|upgrade_needed|plugins_loaded|admin_init" inc/ --type php | head -30Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 3055
🏁 Script executed:
#!/bin/bash
# Check class-default-content-installer.php for where _install_create_emails is defined and called
grep -n "_install_create_emails\|public function\|add_action\|do_action" inc/installers/class-default-content-installer.php | head -30Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 435
🏁 Script executed:
#!/bin/bash
# Look for Default_Content_Installer usage
rg -n "Default_Content_Installer|new Default_Content_Installer|->get_instance\(\)" inc/ --type php | head -20Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 1052
🏁 Script executed:
#!/bin/bash
# Check the main plugin file for activation/upgrade hooks
git ls-files | grep -E "(ultimate-multisite\.php|wp-ultimo\.php|main\.php)" | head -5Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 247
🏁 Script executed:
#!/bin/bash
# Search for version checks and upgrade logic in main plugin files
rg -n "register_activation_hook|register_deactivation_hook|wp_version|get_option.*version" . --type php --max-count=30Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 2290
🏁 Script executed:
#!/bin/bash
# Check what on_activation does in class-hooks.php
sed -n '38,150p' inc/class-hooks.phpRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 2625
🏁 Script executed:
#!/bin/bash
# Search for any automatic email creation or upgrade checks
rg -n "on_activation|_run_migration|create_all_system_emails" inc/class-hooks.php -A 20Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 1662
🏁 Script executed:
#!/bin/bash
# Search for handlers of wu_activation hook
rg -n "add_action.*wu_activation|do_action.*wu_activation" inc/ --type php -A 5 -B 2Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 406
🏁 Script executed:
#!/bin/bash
# Search for Migrator instantiation and triggering
rg -n "Migrator|_run_migration|migration" inc/class-wp-ultimo.php -A 3 -B 2 | head -50Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
🏁 Script executed:
#!/bin/bash
# Search for Migrator class instantiation and method calls
rg -n "new Migrator|Migrator::get_instance|->run\(\)|->execute\(\)|_run_migration" . --type php | head -40Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 891
🏁 Script executed:
#!/bin/bash
# Check if there's any automatic email seeding for upgrades
rg -n "version_compare|old_version|new_version|update_network_option.*version" inc/managers/class-email-manager.php inc/installers/ --type php | head -20Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
🏁 Script executed:
#!/bin/bash
# Look for any upgrade/delta installation logic in core installer
cat inc/installers/class-core-installer.php | head -100Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 3222
🏁 Script executed:
#!/bin/bash
# Look for email reset/import/sync functionality
rg -n "reset.*email|import.*email|sync.*email|reseed|backfill" inc/admin-pages/class-email-list-admin-page.php -B 2 -A 5Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 3577
🏁 Script executed:
#!/bin/bash
# Check the email reset/import modal to see if it backfills missing emails
sed -n '346,370p' inc/admin-pages/class-email-list-admin-page.phpRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 800
🏁 Script executed:
#!/bin/bash
# Search for AJAX handlers for email import/reset
rg -n "handle_reset|handle_import|import_emails|reset_emails" inc/admin-pages/class-email-list-admin-page.php -A 10Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 4053
Ensure new system emails auto-seed on upgraded installations, or document the manual import step.
New demo_site_expiring_* emails won't auto-seed on existing installs. send_system_email() only backfills defaults when the email table is completely empty, so sites with older system emails already created will remain without these new templates. While a manual "Import System Emails" admin feature exists (admin → Email Settings → Import), this requires admin knowledge and action. Consider either:
- Automatically creating missing default emails when registering new ones
- Adding a setup/migration step that seeds these for existing installs
- Prominently documenting the import requirement for upgrade guides
Without intervention, the demo_site_expiring event will silently have no email to send.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@inc/managers/class-email-manager.php` around lines 536 - 560, The new
demo_site_expiring system emails are only registered via
register_default_system_email but won't be seeded into existing installs because
send_system_email only backfills when the emails table is empty; update the
registration flow to ensure missing defaults are created for upgrades by
checking for the existence of each slug (e.g., 'demo_site_expiring_customer' and
'demo_site_expiring_admin') during registration (in the code that calls
register_default_system_email) and insert the default record if missing, or add
a migration/setup routine that runs once on plugin upgrade to call the same
insert logic for any newly introduced events (reference demo_site_expiring,
register_default_system_email and send_system_email to locate the relevant
logic).
| if ($site->is_demo()) { | ||
| if ($site->is_keep_until_live()) { | ||
| wu_log_add( | ||
| 'demo-sites', | ||
| sprintf( | ||
| // translators: %d is the site ID. | ||
| __('Demo site #%d created in keep-until-live mode (no expiration set).', 'ultimate-multisite'), | ||
| $site->get_id() | ||
| ) | ||
| ); | ||
| } else { | ||
| $expires_at = $site->calculate_demo_expiration(); | ||
| $site->set_demo_expires_at($expires_at); | ||
|
|
||
| wu_log_add( | ||
| 'demo-sites', | ||
| sprintf( | ||
| // translators: %1$d is site ID, %2$s is expiration datetime. | ||
| __('Demo site #%1$d created, expires at %2$s', 'ultimate-multisite'), | ||
| $site->get_id(), | ||
| $expires_at | ||
| ) | ||
| ); | ||
| } |
There was a problem hiding this comment.
Do not set demo_expires_at when automatic deletion is disabled.
The settings contract says demo_duration = 0 disables automatic deletion, but this branch still calculates an expiration and stores “now”. The next hourly cleanup will treat a freshly created demo as already expired and queue it for deletion.
💡 Suggested fix
if ($site->is_demo()) {
if ($site->is_keep_until_live()) {
wu_log_add(
'demo-sites',
sprintf(
// translators: %d is the site ID.
__('Demo site #%d created in keep-until-live mode (no expiration set).', 'ultimate-multisite'),
$site->get_id()
)
);
} else {
- $expires_at = $site->calculate_demo_expiration();
- $site->set_demo_expires_at($expires_at);
-
- wu_log_add(
- 'demo-sites',
- sprintf(
- // translators: %1$d is site ID, %2$s is expiration datetime.
- __('Demo site #%1$d created, expires at %2$s', 'ultimate-multisite'),
- $site->get_id(),
- $expires_at
- )
- );
+ $duration = (int) wu_get_setting('demo_duration', 2);
+
+ if ($duration > 0) {
+ $expires_at = $site->calculate_demo_expiration(
+ $duration,
+ (string) wu_get_setting('demo_duration_unit', 'hour')
+ );
+ $site->set_demo_expires_at($expires_at);
+
+ wu_log_add(
+ 'demo-sites',
+ sprintf(
+ // translators: %1$d is site ID, %2$s is expiration datetime.
+ __('Demo site #%1$d created, expires at %2$s', 'ultimate-multisite'),
+ $site->get_id(),
+ $expires_at
+ )
+ );
+ }
}
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@inc/managers/class-site-manager.php` around lines 271 - 294, The code sets
demo_expires_at for every demo site that isn't keep-until-live, which wrongly
includes cases where automatic deletion is disabled (demo_duration == 0); wrap
the calculate_demo_expiration() / set_demo_expires_at() calls in an explicit
check that automatic demo deletion is enabled (i.e. demo duration > 0) before
computing or saving an expiration; in practice, add a conditional before calling
$site->calculate_demo_expiration() and $site->set_demo_expires_at($expires_at)
(use your settings getter or the existing settings contract to test
demo_duration != 0) so freshly created demos aren’t marked expired when
auto-deletion is disabled.
| $payload = [ | ||
| 'site' => $site->to_array(), | ||
| 'membership' => $membership ? $membership->to_array() : [], | ||
| 'customer' => $customer->to_array(), | ||
| 'demo_expires_at' => $expires_at, | ||
| 'demo_time_remaining' => $time_remaining_human, | ||
| 'site_admin_url' => get_admin_url($site->get_blog_id()), | ||
| 'site_url' => $site->get_active_site_url(), | ||
| ]; | ||
|
|
||
| // Fire the demo_site_expiring event. | ||
| wu_do_event('demo_site_expiring', $payload); |
There was a problem hiding this comment.
Build the expiring-event payload in the flattened schema.
views/emails/customer/demo-site-expiring.php and views/emails/admin/demo-site-expiring.php use flat tokens like {{site_title}}, {{customer_name}}, {{site_manage_url}}, and {{customer_manage_url}}. This payload only passes nested site / membership / customer arrays, so those computed keys are never available when demo_site_expiring fires.
💡 Suggested fix
- $payload = [
- 'site' => $site->to_array(),
- 'membership' => $membership ? $membership->to_array() : [],
- 'customer' => $customer->to_array(),
- 'demo_expires_at' => $expires_at,
- 'demo_time_remaining' => $time_remaining_human,
- 'site_admin_url' => get_admin_url($site->get_blog_id()),
- 'site_url' => $site->get_active_site_url(),
- ];
+ $payload = array_merge(
+ wu_generate_event_payload('site', $site),
+ wu_generate_event_payload('membership', $membership),
+ wu_generate_event_payload('customer', $customer),
+ [
+ 'demo_expires_at' => $expires_at,
+ 'demo_time_remaining' => $time_remaining_human,
+ 'site_admin_url' => get_admin_url($site->get_blog_id()),
+ 'site_url' => $site->get_active_site_url(),
+ ]
+ );🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@inc/managers/class-site-manager.php` around lines 1071 - 1082, The payload
passed to wu_do_event('demo_site_expiring') is only nested arrays so the flat
template tokens aren't available; update the $payload built in
class-site-manager (the $payload variable before wu_do_event) to include
flattened keys used by the email templates such as site_title (from
$site->get_title() or $site->to_array()['title']), customer_name (from
$customer->get_name() or $customer->to_array()['name']), site_manage_url
(get_admin_url($site->get_blog_id())), customer_manage_url
(get_admin_url($customer->get_blog_id()) or appropriate customer admin URL),
plus any computed tokens like site_url, site_admin_url, demo_expires_at and
demo_time_remaining so templates referencing {{site_title}}, {{customer_name}},
{{site_manage_url}}, {{customer_manage_url}} will be present when
wu_do_event('demo_site_expiring') is fired.
| // Delete the WordPress blog if it exists. | ||
| if ($blog_id) { | ||
| wpmu_delete_blog($blog_id, true); | ||
| } | ||
|
|
||
| // Delete the WP Ultimo site record. | ||
| $result = $site->delete(); | ||
|
|
||
| // Optionally delete the membership if it only has this demo site. | ||
| $delete_membership = apply_filters('wu_demo_site_delete_membership', true, $membership, $site); | ||
|
|
||
| if ($delete_membership && $membership) { | ||
| $membership_sites = $membership->get_sites(); | ||
|
|
||
| // Only delete if this was the only site on the membership. | ||
| if (empty($membership_sites) || count($membership_sites) === 0) { | ||
| $membership->delete(); | ||
| } | ||
| } | ||
|
|
||
| // Optionally delete the customer if they only had this demo. | ||
| $delete_customer = apply_filters('wu_demo_site_delete_customer', wu_get_setting('demo_delete_customer', false), $customer, $site); | ||
|
|
||
| if ($delete_customer && $customer) { | ||
| $customer_memberships = $customer->get_memberships(); | ||
|
|
||
| // Only delete if this was their only membership. | ||
| if (empty($customer_memberships) || count($customer_memberships) === 0) { | ||
| $user_id = $customer->get_user_id(); | ||
|
|
||
| $customer->delete(); | ||
|
|
||
| // Delete the WordPress user as well. | ||
| if ($user_id && apply_filters('wu_demo_site_delete_user', true, $user_id)) { | ||
| require_once ABSPATH . 'wp-admin/includes/user.php'; | ||
| wpmu_delete_user($user_id); | ||
| } | ||
| } |
There was a problem hiding this comment.
Delete the site once, and only continue cleanup on success.
This flow runs two site-delete steps and then ignores whether the final one actually succeeded before deleting the membership, customer, and user. In a partial-failure path, that can leave a surviving site orphaned from its ownership and billing records.
💡 Suggested fix
- // Delete the WordPress blog if it exists.
- if ($blog_id) {
- wpmu_delete_blog($blog_id, true);
- }
-
- // Delete the WP Ultimo site record.
- $result = $site->delete();
+ // Delete the site once, then stop if the deletion did not complete.
+ $result = $site->delete();
+
+ if (true !== $result) {
+ wu_log_add(
+ 'demo-cleanup',
+ sprintf(
+ __('Failed to delete demo site #%d; skipping related cleanup.', 'ultimate-multisite'),
+ $site_id
+ )
+ );
+
+ return;
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Delete the WordPress blog if it exists. | |
| if ($blog_id) { | |
| wpmu_delete_blog($blog_id, true); | |
| } | |
| // Delete the WP Ultimo site record. | |
| $result = $site->delete(); | |
| // Optionally delete the membership if it only has this demo site. | |
| $delete_membership = apply_filters('wu_demo_site_delete_membership', true, $membership, $site); | |
| if ($delete_membership && $membership) { | |
| $membership_sites = $membership->get_sites(); | |
| // Only delete if this was the only site on the membership. | |
| if (empty($membership_sites) || count($membership_sites) === 0) { | |
| $membership->delete(); | |
| } | |
| } | |
| // Optionally delete the customer if they only had this demo. | |
| $delete_customer = apply_filters('wu_demo_site_delete_customer', wu_get_setting('demo_delete_customer', false), $customer, $site); | |
| if ($delete_customer && $customer) { | |
| $customer_memberships = $customer->get_memberships(); | |
| // Only delete if this was their only membership. | |
| if (empty($customer_memberships) || count($customer_memberships) === 0) { | |
| $user_id = $customer->get_user_id(); | |
| $customer->delete(); | |
| // Delete the WordPress user as well. | |
| if ($user_id && apply_filters('wu_demo_site_delete_user', true, $user_id)) { | |
| require_once ABSPATH . 'wp-admin/includes/user.php'; | |
| wpmu_delete_user($user_id); | |
| } | |
| } | |
| // Delete the site once, then stop if the deletion did not complete. | |
| $result = $site->delete(); | |
| if (true !== $result) { | |
| wu_log_add( | |
| 'demo-cleanup', | |
| sprintf( | |
| __('Failed to delete demo site #%d; skipping related cleanup.', 'ultimate-multisite'), | |
| $site_id | |
| ) | |
| ); | |
| return; | |
| } | |
| // Optionally delete the membership if it only has this demo site. | |
| $delete_membership = apply_filters('wu_demo_site_delete_membership', true, $membership, $site); | |
| if ($delete_membership && $membership) { | |
| $membership_sites = $membership->get_sites(); | |
| // Only delete if this was the only site on the membership. | |
| if (empty($membership_sites) || count($membership_sites) === 0) { | |
| $membership->delete(); | |
| } | |
| } | |
| // Optionally delete the customer if they only had this demo. | |
| $delete_customer = apply_filters('wu_demo_site_delete_customer', wu_get_setting('demo_delete_customer', false), $customer, $site); | |
| if ($delete_customer && $customer) { | |
| $customer_memberships = $customer->get_memberships(); | |
| // Only delete if this was their only membership. | |
| if (empty($customer_memberships) || count($customer_memberships) === 0) { | |
| $user_id = $customer->get_user_id(); | |
| $customer->delete(); | |
| // Delete the WordPress user as well. | |
| if ($user_id && apply_filters('wu_demo_site_delete_user', true, $user_id)) { | |
| require_once ABSPATH . 'wp-admin/includes/user.php'; | |
| wpmu_delete_user($user_id); | |
| } | |
| } |
🧰 Tools
🪛 PHPMD (2.15.0)
[warning] 1128-1128: Avoid unused local variables such as '$result'. (undefined)
(UnusedLocalVariable)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@inc/managers/class-site-manager.php` around lines 1122 - 1159, The site
delete flow must only run cleanup if the actual deletion succeeded: call
wpmu_delete_blog($blog_id, true) (when $blog_id) and capture its return; call
$site->delete() and capture its $result, then only proceed to the
membership/customer/user cleanup (use $membership->get_sites(),
$membership->delete(), $customer->get_memberships(), $customer->delete(),
wpmu_delete_user()) when the deletion steps returned success; if either deletion
fails, stop further deletes and surface/log the failure instead of orphaning
records.
| $saved = $site->save(); | ||
|
|
||
| if ( ! $saved) { | ||
| return new \WP_Error('save_failed', __('Failed to activate the demo site. Please try again.', 'ultimate-multisite')); | ||
| } |
There was a problem hiding this comment.
Treat anything other than true from save() as a failed conversion.
With the current guard, a failed save can still fall through to the success log and wu_after_demo_site_converted hook, even though the site never left demo mode.
💡 Suggested fix
$saved = $site->save();
- if ( ! $saved) {
+ if (true !== $saved) {
return new \WP_Error('save_failed', __('Failed to activate the demo site. Please try again.', 'ultimate-multisite'));
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| $saved = $site->save(); | |
| if ( ! $saved) { | |
| return new \WP_Error('save_failed', __('Failed to activate the demo site. Please try again.', 'ultimate-multisite')); | |
| } | |
| $saved = $site->save(); | |
| if (true !== $saved) { | |
| return new \WP_Error('save_failed', __('Failed to activate the demo site. Please try again.', 'ultimate-multisite')); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@inc/managers/class-site-manager.php` around lines 1337 - 1341, The current
check treats any truthy value from $site->save() as success; change the guard
around the call to $site->save() (the $saved variable) to treat only a strict
boolean true as success (e.g., if ($saved !== true) return new \WP_Error(...));
ensure this early return prevents the success logging and the
wu_after_demo_site_converted hook from running when $site->save() does not
return true.
| <td style="text-align: right; width: 160px; padding: 8px; background: #f9f9f9; border: 1px solid #eee;"><b><?php esc_html_e('Admin Panel', 'ultimate-multisite'); ?></b></td> | ||
| <td style="padding: 8px; background: #fff; border: 1px solid #eee;"> | ||
| <a href="{{customer_manage_url}}" style="text-decoration: none;" rel="nofollow"><?php esc_html_e('Go to Customer', 'ultimate-multisite'); ?></a> |
There was a problem hiding this comment.
Rename the customer action row.
Line 86 says “Admin Panel”, but the destination is {{customer_manage_url}} and the CTA is “Go to Customer”. That reads like the site wp-admin, not the network customer screen.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@views/emails/admin/demo-site-expiring.php` around lines 86 - 88, The table
label currently uses esc_html_e('Admin Panel', 'ultimate-multisite') but the
link points to {{customer_manage_url}} with the CTA esc_html_e('Go to Customer',
'ultimate-multisite'); update the label to match the destination/CTA (for
example esc_html_e('Customer', 'ultimate-multisite') or esc_html_e('Manage
Customer', 'ultimate-multisite')) so the row consistently describes the link;
modify the string inside the first <td> that contains esc_html_e('Admin Panel',
'ultimate-multisite') accordingly.
Summary by CodeRabbit