Skip to content

Demo product#362

Open
superdav42 wants to merge 2 commits intomainfrom
demo-product
Open

Demo product#362
superdav42 wants to merge 2 commits intomainfrom
demo-product

Conversation

@superdav42
Copy link
Collaborator

@superdav42 superdav42 commented Mar 9, 2026

Summary by CodeRabbit

  • New Features
    • Added demo product and site types with configurable expiration behavior (automatic deletion or conversion to live).
    • Implemented automated demo site lifecycle management including expiration tracking and scheduled cleanup.
    • Added customer notifications about impending demo site expiration.
    • Enabled demo-to-live site conversion through admin interface.
    • Introduced demo site configuration settings for duration and behavior.
    • Enhanced admin dashboard with demo mode indicators and quick-action links.

superdav42 and others added 2 commits March 7, 2026 09:21
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>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 9, 2026

📝 Walkthrough

Walkthrough

This 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

Cohort / File(s) Summary
Demo Type Enums
inc/database/products/class-product-type.php, inc/database/sites/class-site-type.php
Added DEMO constant with orange styling and labels to Product_Type and Site_Type enum classes.
Product Admin UI & Settings
inc/admin-pages/class-product-edit-admin-page.php, inc/class-settings.php
Added "Demo Settings" section to product edit page with demo_behavior select control; added comprehensive "Demo Sites" configuration block in settings with duration, deletion, notification, and go-live URL fields.
Product & Site Models
inc/models/class-product.php, inc/models/class-site.php
Extended Product with demo_behavior property/accessors and is_keep_until_live() check; extended Site with demo expiration tracking, helper methods (is_demo, is_keep_until_live, get/set demo expiration, calculate expiration, time remaining with human-readable format).
Demo Site Lifecycle Management
inc/managers/class-site-manager.php
Introduces automated demo lifecycle: expiration calculation on publish, hourly checks for expired/expiring sites, async deletion with cascading cleanup, frontend access blocking for keep-until-live demos, admin bar demo mode badge, go-live conversion handler (convert_demo_to_live method), and customer notifications with configurable warning window.
Checkout & Product Types
inc/checkout/class-checkout.php, inc/functions/product.php
Modified checkout to set site_type to DEMO when product plan is DEMO type; updated wu_is_plan_type filter to recognize demo products as plan-like by default.
Events & Email Notifications
inc/managers/class-event-manager.php, inc/managers/class-email-manager.php, views/emails/admin/demo-site-expiring.php, views/emails/customer/demo-site-expiring.php
Registered demo_site_expiring webhook event with rich payload (site, membership, customer, timestamps, URLs); added customer and admin default system email templates; customer template informs about expiration and upgrade option; admin template includes site/customer details with action links.
List Tables & Utilities
inc/list-tables/class-site-list-table.php
Added "demo" view filter to site list table for type-based filtering.

Sequence Diagrams

sequenceDiagram
    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
Loading
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
Loading
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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Skip loading admin pages on frontend/cron requests #335: Modifies inc/admin-pages/class-product-edit-admin-page.php to add demo settings section alongside existing product option changes—potential code path overlap in admin page rendering.
  • Enhancements #346: Both PRs add new event registrations in inc/managers/class-event-manager.php and default system emails in inc/managers/class-email-manager.php—direct code-level integration point for event/email infrastructure.

Poem

🐰 Hop, hop! Demo sites now bloom and fade,
Timers tick while customers engage,
Expiry whispers, then auto-delete
But keep-until-live lets dreams compete,
Go-live magic turns trials to keepsakes! 🎪✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title 'Demo product' is vague and overly generic, lacking specificity about the extensive implementation details introduced across multiple files. Consider a more descriptive title that captures the primary change, such as 'Add demo site type with lifecycle management' or 'Implement demo product and site expiration feature'.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch demo-product

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link

github-actions bot commented Mar 9, 2026

🔨 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!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 18c9c97 and 21d31e0.

📒 Files selected for processing (14)
  • inc/admin-pages/class-product-edit-admin-page.php
  • inc/checkout/class-checkout.php
  • inc/class-settings.php
  • inc/database/products/class-product-type.php
  • inc/database/sites/class-site-type.php
  • inc/functions/product.php
  • inc/list-tables/class-site-list-table.php
  • inc/managers/class-email-manager.php
  • inc/managers/class-event-manager.php
  • inc/managers/class-site-manager.php
  • inc/models/class-product.php
  • inc/models/class-site.php
  • views/emails/admin/demo-site-expiring.php
  • views/emails/customer/demo-site-expiring.php

Comment on lines +1389 to +1402
$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;',
],
]
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +1431 to +1455
$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;',
],
]
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +192 to +199
* 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']);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +536 to +560
/*
* 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'),
]
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 -40

Repository: 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 -30

Repository: 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 -30

Repository: 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 -20

Repository: 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 -5

Repository: 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=30

Repository: 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.php

Repository: 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 20

Repository: 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 2

Repository: 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 -50

Repository: 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 -40

Repository: 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 -20

Repository: 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 -100

Repository: 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 5

Repository: 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.php

Repository: 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 10

Repository: 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:

  1. Automatically creating missing default emails when registering new ones
  2. Adding a setup/migration step that seeds these for existing installs
  3. 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).

Comment on lines +271 to +294
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
)
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Comment on lines +1071 to +1082
$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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +1122 to +1159
// 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);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
// 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.

Comment on lines +1337 to +1341
$saved = $site->save();

if ( ! $saved) {
return new \WP_Error('save_failed', __('Failed to activate the demo site. Please try again.', 'ultimate-multisite'));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
$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.

Comment on lines +86 to +88
<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>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant