diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index ed9fee4f..c0967e76 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -912,6 +912,14 @@ protected function load_managers(): void { */ WP_Ultimo\Managers\Site_Manager::get_instance(); + /* + * Loads the Post-Signup Activity manager. + * + * Tracks post creation, CPT creation, user registration, and + * WooCommerce orders on managed subsites (issue #399). + */ + WP_Ultimo\Managers\Post_Signup_Activity_Manager::get_instance(); + /* * Loads the Checkout Form manager. */ diff --git a/inc/managers/class-post-signup-activity-manager.php b/inc/managers/class-post-signup-activity-manager.php new file mode 100644 index 00000000..fa643629 --- /dev/null +++ b/inc/managers/class-post-signup-activity-manager.php @@ -0,0 +1,376 @@ + __('Subsite Post Created', 'ultimate-multisite'), + 'desc' => __('Fired when a customer publishes a new post on their subsite.', 'ultimate-multisite'), + 'payload' => fn() => array_merge( + [ + 'post_id' => 1, + 'post_title' => 'Example Post', + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_author_id' => 1, + ], + wu_generate_event_payload('site'), + wu_generate_event_payload('membership') + ), + 'deprecated_args' => [], + ] + ); + + wu_register_event_type( + 'subsite_cpt_created', + [ + 'name' => __('Subsite Custom Post Type Entry Created', 'ultimate-multisite'), + 'desc' => __('Fired when a customer creates a new entry of a custom post type on their subsite.', 'ultimate-multisite'), + 'payload' => fn() => array_merge( + [ + 'post_id' => 1, + 'post_title' => 'Example CPT Entry', + 'post_type' => 'product', + 'post_status' => 'publish', + 'post_author_id' => 1, + ], + wu_generate_event_payload('site'), + wu_generate_event_payload('membership') + ), + 'deprecated_args' => [], + ] + ); + + wu_register_event_type( + 'subsite_user_registered', + [ + 'name' => __('Subsite User Registered', 'ultimate-multisite'), + 'desc' => __('Fired when a new user is added to a customer subsite.', 'ultimate-multisite'), + 'payload' => fn() => array_merge( + [ + 'new_user_id' => 1, + 'new_user_login' => 'example_user', + 'new_user_email' => 'user@example.com', + 'new_user_role' => 'subscriber', + ], + wu_generate_event_payload('site'), + wu_generate_event_payload('membership') + ), + 'deprecated_args' => [], + ] + ); + + wu_register_event_type( + 'subsite_woocommerce_order', + [ + 'name' => __('Subsite WooCommerce Order Placed', 'ultimate-multisite'), + 'desc' => __('Fired when a WooCommerce order is created on a customer subsite.', 'ultimate-multisite'), + 'payload' => fn() => array_merge( + [ + 'order_id' => 1, + 'order_total' => '99.00', + 'order_status' => 'pending', + 'order_items' => 1, + ], + wu_generate_event_payload('site'), + wu_generate_event_payload('membership') + ), + 'deprecated_args' => [], + ] + ); + } + + /** + * Registers hooks that run on customer subsites. + * + * Only registers on non-main sites that are WU-managed. + * + * @since 2.5.0 + * @return void + */ + public function register_subsite_hooks(): void { + + // Skip the main site — we only track customer subsites. + if (is_main_site()) { + return; + } + + // Track post/CPT creation (fires on publish transition). + add_action('transition_post_status', [$this, 'track_post_published'], 10, 3); + + // Track new user added to this subsite. + add_action('add_user_to_blog', [$this, 'track_user_added_to_blog'], 10, 3); + + // Track WooCommerce orders (fires when a new order is created). + add_action('woocommerce_new_order', [$this, 'track_woocommerce_order'], 10, 2); + } + + /** + * Tracks when a post transitions to "publish" status for the first time. + * + * @since 2.5.0 + * + * @param string $new_status The new post status. + * @param string $old_status The old post status. + * @param \WP_Post $post The post object. + * @return void + */ + public function track_post_published(string $new_status, string $old_status, \WP_Post $post): void { + + // Only fire on the first publish transition. + if ('publish' !== $new_status || 'publish' === $old_status) { + return; + } + + // Skip excluded/internal post types. + if (in_array($post->post_type, self::EXCLUDED_POST_TYPES, true)) { + return; + } + + $blog_id = get_current_blog_id(); + $wu_site = $this->get_wu_site($blog_id); + + if ( ! $wu_site) { + return; + } + + $is_cpt = 'post' !== $post->post_type && 'page' !== $post->post_type; + $slug = $is_cpt ? 'subsite_cpt_created' : 'subsite_post_created'; + + $payload = $this->build_site_membership_payload($wu_site); + $payload = array_merge( + $payload, + [ + 'post_id' => $post->ID, + 'post_title' => $post->post_title, + 'post_type' => $post->post_type, + 'post_status' => $post->post_status, + 'post_author_id' => (int) $post->post_author, + 'object_type' => 'site', + 'object_id' => $wu_site->get_id(), + ] + ); + + wu_create_event( + [ + 'severity' => Event::SEVERITY_INFO, + 'slug' => $slug, + 'initiator' => 'system', + 'object_type' => 'site', + 'object_id' => $wu_site->get_id(), + 'payload' => $payload, + ] + ); + } + + /** + * Tracks when a user is added to a subsite. + * + * @since 2.5.0 + * + * @param int $user_id The user ID being added. + * @param string $role The role assigned. + * @param int $blog_id The blog ID the user is being added to. + * @return void + */ + public function track_user_added_to_blog(int $user_id, string $role, int $blog_id): void { + + $wu_site = $this->get_wu_site($blog_id); + + if ( ! $wu_site) { + return; + } + + $user = get_userdata($user_id); + + $payload = $this->build_site_membership_payload($wu_site); + $payload = array_merge( + $payload, + [ + 'new_user_id' => $user_id, + 'new_user_login' => $user ? $user->user_login : '', + 'new_user_email' => $user ? $user->user_email : '', + 'new_user_role' => $role, + 'object_type' => 'site', + 'object_id' => $wu_site->get_id(), + ] + ); + + wu_create_event( + [ + 'severity' => Event::SEVERITY_INFO, + 'slug' => 'subsite_user_registered', + 'initiator' => 'system', + 'object_type' => 'site', + 'object_id' => $wu_site->get_id(), + 'payload' => $payload, + ] + ); + } + + /** + * Tracks when a WooCommerce order is created on a subsite. + * + * @since 2.5.0 + * + * @param int $order_id The WooCommerce order ID. + * @param \WC_Order $order The WooCommerce order object. + * @return void + */ + public function track_woocommerce_order(int $order_id, $order): void { + + $blog_id = get_current_blog_id(); + $wu_site = $this->get_wu_site($blog_id); + + if ( ! $wu_site) { + return; + } + + $order_total = $order ? $order->get_total() : 0; + $order_status = $order ? $order->get_status() : ''; + $order_items = $order ? count($order->get_items()) : 0; + + $payload = $this->build_site_membership_payload($wu_site); + $payload = array_merge( + $payload, + [ + 'order_id' => $order_id, + 'order_total' => (string) $order_total, + 'order_status' => $order_status, + 'order_items' => $order_items, + 'object_type' => 'site', + 'object_id' => $wu_site->get_id(), + ] + ); + + wu_create_event( + [ + 'severity' => Event::SEVERITY_INFO, + 'slug' => 'subsite_woocommerce_order', + 'initiator' => 'system', + 'object_type' => 'site', + 'object_id' => $wu_site->get_id(), + 'payload' => $payload, + ] + ); + } + + /** + * Looks up the WU site object for a given WordPress blog ID. + * + * Returns false if the blog is not a WU-managed customer site. + * + * @since 2.5.0 + * + * @param int $blog_id The WordPress blog ID. + * @return \WP_Ultimo\Models\Site|false + */ + protected function get_wu_site(int $blog_id) { + + if ( ! function_exists('wu_get_site')) { + return false; + } + + $wu_site = wu_get_site($blog_id); + + if ( ! $wu_site || ! $wu_site->get_membership_id()) { + return false; + } + + return $wu_site; + } + + /** + * Builds the site and membership payload arrays for an event. + * + * @since 2.5.0 + * + * @param \WP_Ultimo\Models\Site $wu_site The WU site object. + * @return array + */ + protected function build_site_membership_payload($wu_site): array { + + $payload = wu_generate_event_payload('site', $wu_site); + + $membership = $wu_site->get_membership(); + + if ($membership) { + $payload = array_merge($payload, wu_generate_event_payload('membership', $membership)); + } + + return $payload; + } +} diff --git a/tests/WP_Ultimo/Managers/Post_Signup_Activity_Manager_Test.php b/tests/WP_Ultimo/Managers/Post_Signup_Activity_Manager_Test.php new file mode 100644 index 00000000..26147ea6 --- /dev/null +++ b/tests/WP_Ultimo/Managers/Post_Signup_Activity_Manager_Test.php @@ -0,0 +1,186 @@ +manager = Post_Signup_Activity_Manager::get_instance(); + } + + /** + * The manager is a singleton and returns the same instance. + */ + public function test_singleton_returns_same_instance(): void { + + $a = Post_Signup_Activity_Manager::get_instance(); + $b = Post_Signup_Activity_Manager::get_instance(); + + $this->assertSame($a, $b); + } + + /** + * register_event_types registers all four expected event slugs. + */ + public function test_register_event_types_registers_all_slugs(): void { + + // Trigger registration. + $this->manager->register_event_types(); + + $expected_slugs = [ + 'subsite_post_created', + 'subsite_cpt_created', + 'subsite_user_registered', + 'subsite_woocommerce_order', + ]; + + foreach ($expected_slugs as $slug) { + $event = wu_get_event_type($slug); + $this->assertIsArray($event, "Event type '{$slug}' should be registered."); + $this->assertArrayHasKey('name', $event, "Event type '{$slug}' should have a 'name' key."); + $this->assertArrayHasKey('desc', $event, "Event type '{$slug}' should have a 'desc' key."); + } + } + + /** + * on_post_published does nothing when the post status is not 'publish'. + */ + public function test_on_post_published_ignores_non_publish_status(): void { + + $post = $this->factory->post->create_and_get( + [ + 'post_status' => 'draft', + 'post_type' => 'post', + ] + ); + + $events_before = wu_get_events(['number' => 9999]); + + // Simulate a draft-to-draft transition (should be ignored). + $this->manager->on_post_published('draft', 'draft', $post); + + $events_after = wu_get_events(['number' => 9999]); + + $this->assertCount( + count($events_before), + $events_after, + 'No new event should be created for a non-publish transition.' + ); + } + + /** + * on_post_published does nothing when old and new status are both 'publish'. + */ + public function test_on_post_published_ignores_re_save_of_published_post(): void { + + $post = $this->factory->post->create_and_get( + [ + 'post_status' => 'publish', + 'post_type' => 'post', + ] + ); + + $events_before = wu_get_events(['number' => 9999]); + + // Simulate a publish-to-publish transition (re-save, should be ignored). + $this->manager->on_post_published('publish', 'publish', $post); + + $events_after = wu_get_events(['number' => 9999]); + + $this->assertCount( + count($events_before), + $events_after, + 'No new event should be created when re-saving an already-published post.' + ); + } + + /** + * on_post_published does nothing for excluded post types. + */ + public function test_on_post_published_ignores_excluded_post_types(): void { + + $post = $this->factory->post->create_and_get( + [ + 'post_status' => 'draft', + 'post_type' => 'revision', + ] + ); + + $events_before = wu_get_events(['number' => 9999]); + + $this->manager->on_post_published('publish', 'draft', $post); + + $events_after = wu_get_events(['number' => 9999]); + + $this->assertCount( + count($events_before), + $events_after, + 'No event should be created for excluded post types like "revision".' + ); + } + + /** + * get_wu_site returns false when wu_get_site is not available. + */ + public function test_get_wu_site_returns_false_for_unknown_blog(): void { + + // Use reflection to call the protected method. + $reflection = new \ReflectionClass($this->manager); + $method = $reflection->getMethod('get_wu_site'); + $method->setAccessible(true); + + // Blog ID 99999 should not exist. + $result = $method->invoke($this->manager, 99999); + + $this->assertFalse($result, 'get_wu_site should return false for an unknown blog ID.'); + } + + /** + * EXCLUDED_POST_TYPES constant contains expected internal types. + */ + public function test_excluded_post_types_contains_revision(): void { + + $this->assertContains( + 'revision', + Post_Signup_Activity_Manager::EXCLUDED_POST_TYPES, + '"revision" must be in the excluded post types list.' + ); + } + + /** + * EXCLUDED_POST_TYPES constant contains auto-draft. + */ + public function test_excluded_post_types_contains_auto_draft(): void { + + $this->assertContains( + 'auto-draft', + Post_Signup_Activity_Manager::EXCLUDED_POST_TYPES, + '"auto-draft" must be in the excluded post types list.' + ); + } +}