From c6d7bbb7c144994294b57bba0713ea2981a3fd62 Mon Sep 17 00:00:00 2001 From: Ian Dunn Date: Thu, 28 Apr 2022 15:05:44 -0700 Subject: [PATCH] Contributors: Reset profile hours when pledge deactivated. See https://github.com/WordPress/five-for-the-future/issues/169 --- plugins/wporg-5ftf/includes/contributor.php | 31 ++ plugins/wporg-5ftf/includes/email.php | 18 +- plugins/wporg-5ftf/includes/pledge.php | 4 + plugins/wporg-5ftf/includes/xprofile.php | 18 ++ plugins/wporg-5ftf/tests/test-auth.php | 3 + plugins/wporg-5ftf/tests/test-contributor.php | 268 ++++++++++++++++++ 6 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 plugins/wporg-5ftf/tests/test-contributor.php diff --git a/plugins/wporg-5ftf/includes/contributor.php b/plugins/wporg-5ftf/includes/contributor.php index 2be510b..0d73236 100644 --- a/plugins/wporg-5ftf/includes/contributor.php +++ b/plugins/wporg-5ftf/includes/contributor.php @@ -166,6 +166,22 @@ function add_pledge_contributors( $pledge_id, $contributors ) { return $results; } +/** + * Remove all of the contributors for the given pledge. + * + * Some contributors are sponsored by multiple companies. They'll have a `5ftf_contributor` post for each company, + * but only the post associated with the given pledge should be removed. + */ +function remove_pledge_contributors( int $pledge_id ) : void { + $contributors = get_pledge_contributors( $pledge_id, 'all' ); + + foreach ( $contributors as $status_group ) { + foreach ( $status_group as $contributor ) { + remove_contributor( $contributor->ID ); + } + } +} + /** * Remove a contributor post from a pledge. * @@ -186,6 +202,21 @@ function remove_contributor( $contributor_post_id ) { Email\send_contributor_removed_email( $pledge_id, $contributor ); } + $has_additional_sponsors = get_posts( array( + 'post_type' => CPT_ID, + 'title' => $contributor->post_title, + 'post_status' => 'publish', + ) ); + + // `pending` contributors never confirmed they were associated with the company, so their profile data isn't + // tied to the pledge, and shouldn't be reset. If a user has multiple sponsors, we don't know which hours are + // sponsored by which company, so just leave them all. + if ( 'publish' === $old_status && ! $has_additional_sponsors ) { + $user = get_user_by( 'login', $contributor->post_title ); + + XProfile\reset_contribution_data( $user->ID ); + } + /** * Action: Fires when a contributor is removed from a pledge. * diff --git a/plugins/wporg-5ftf/includes/email.php b/plugins/wporg-5ftf/includes/email.php index 4274530..cf1e711 100644 --- a/plugins/wporg-5ftf/includes/email.php +++ b/plugins/wporg-5ftf/includes/email.php @@ -128,13 +128,23 @@ function send_contributor_confirmation_emails( $pledge_id, $contributor_id = nul function send_contributor_removed_email( $pledge_id, $contributor ) { $pledge = get_post( $pledge_id ); $subject = "Removed from {$pledge->post_title} Five for the Future pledge"; - $message = "Howdy {$contributor->post_title},\n\n"; - $message .= sprintf( - 'This email is to notify you that your WordPress.org contributor profile is no longer linked to %1$s’s Five for the Future pledge. If this is unexpected news, it’s best to reach out directly to %1$s with questions. Have a great day!', + $user = get_user_by( 'login', $contributor->post_title ); + + $message = sprintf( ' + Howdy %1$s, + + This email is to notify you that your WordPress.org contributor profile is no longer linked to %2$s’s Five for the Future pledge. If this is unexpected news, it’s best to reach out directly to %2$s with questions. + + If they were the only sponsor linked to your account, then the "Hours Per Week" and "Contributor Teams" fields on your profile have been reset, so that teams have accurate data. If you still plan on contributing without sponsorship, please revisit your profile and enter your new hours and teams. + + https://profiles.wordpress.org/me/profile/edit/group/5/ + + Have a great day!', + $contributor->post_title, $pledge->post_title ); + $message = str_replace( "\t", '', trim( $message ) ); - $user = get_user_by( 'login', $contributor->post_title ); send_email( $user->user_email, $subject, $message, $pledge_id ); } diff --git a/plugins/wporg-5ftf/includes/pledge.php b/plugins/wporg-5ftf/includes/pledge.php index ff33d79..ec95bb3 100755 --- a/plugins/wporg-5ftf/includes/pledge.php +++ b/plugins/wporg-5ftf/includes/pledge.php @@ -387,6 +387,10 @@ function deactivate( $pledge_id, $notify = false, $reason = '' ) { do_action( FiveForTheFuture\PREFIX . '_deactivated_pledge', $pledge_id, $notify, $reason, $result ); + if ( ! is_wp_error( $result ) ) { + Contributor\remove_pledge_contributors( $pledge_id ); + } + return $result; } diff --git a/plugins/wporg-5ftf/includes/xprofile.php b/plugins/wporg-5ftf/includes/xprofile.php index f904329..87b5471 100644 --- a/plugins/wporg-5ftf/includes/xprofile.php +++ b/plugins/wporg-5ftf/includes/xprofile.php @@ -170,3 +170,21 @@ function get_contributor_user_data( $user_id ) { return $formatted_data; } + +/** + * Reset the 5ftF data on a user's profile. + */ +function reset_contribution_data( $user_id ) : void { + global $wpdb; + + $wpdb->query( $wpdb->prepare( ' + DELETE FROM `bpmain_bp_xprofile_data` + WHERE + user_id = %d AND + field_id IN ( %d, %d, %d )', + $user_id, + FIELD_IDS['sponsored'], + FIELD_IDS['hours_per_week'], + FIELD_IDS['team_names'], + ) ); +} diff --git a/plugins/wporg-5ftf/tests/test-auth.php b/plugins/wporg-5ftf/tests/test-auth.php index 2a9c595..848bc1c 100644 --- a/plugins/wporg-5ftf/tests/test-auth.php +++ b/plugins/wporg-5ftf/tests/test-auth.php @@ -6,6 +6,9 @@ use const WordPressDotOrg\FiveForTheFuture\Pledge\CPT_ID as PLEDGE_POST_TYPE; defined( 'WPINC' ) || die(); +/** + * @group auth + */ class Test_Auth extends WP_UnitTestCase { // phpcs:ignore PSR2.Classes.PropertyDeclaration.Multiple protected static $pledge, $action, $page, $action_url, $token; diff --git a/plugins/wporg-5ftf/tests/test-contributor.php b/plugins/wporg-5ftf/tests/test-contributor.php new file mode 100644 index 0000000..8e15574 --- /dev/null +++ b/plugins/wporg-5ftf/tests/test-contributor.php @@ -0,0 +1,268 @@ +query( " + CREATE TABLE `bpmain_bp_xprofile_data` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `field_id` bigint unsigned NOT NULL DEFAULT '0', + `user_id` bigint unsigned NOT NULL DEFAULT '0', + `value` longtext NOT NULL, + `last_updated` datetime NOT NULL DEFAULT '1970-01-01 00:00:00', + PRIMARY KEY (`id`), + KEY `field_id` (`field_id`), + KEY `user_id` (`user_id`) + ) + ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb3 + " ); + + self::$user_jane_id = self::factory()->user->create( array( + 'user_login' => 'jane', + 'user_email' => 'jane@example.org', + ) ); + + self::$user_ashish_id = self::factory()->user->create( array( + 'user_login' => 'ashish', + 'user_email' => 'ashish@example.org', + ) ); + + self::$page_for_organizations = get_post( self::factory()->post->create( array( + 'post_type' => 'page', + 'post_title' => 'For Organizations', + 'post_status' => 'publish', + ) ) ); + + $GLOBALS['post'] = self::$page_for_organizations; // `create_new_pledge()` assumes this exists. + + self::$pledge_10up_id = Pledge\create_new_pledge( '10up' ); + self::$pledge_bluehost_id = Pledge\create_new_pledge( 'BlueHost' ); + + wp_update_post( array( + 'ID' => self::$pledge_bluehost_id, + 'post_status' => 'publish', + ) ); + wp_update_post( array( + 'ID' => self::$pledge_10up_id, + 'post_status' => 'publish', + ) ); + } + + /** + * Run before every test. + */ + public function set_up() { + global $wpdb; + + parent::set_up(); + + $wpdb->query( 'TRUNCATE TABLE `bpmain_bp_xprofile_data` ' ); + + $wpdb->query( $wpdb->prepare( " + INSERT INTO `bpmain_bp_xprofile_data` + (`id`, `field_id`, `user_id`, `value`, `last_updated`) + VALUES + (NULL, 29, %d, '40', '2019-12-02 10:00:00' ), + (NULL, 30, %d, 'a:1:{i:0;s:9:\"Core Team\";}', '2019-12-03 11:00:00' ), + (NULL, 29, %d, '35', '2019-12-02 10:00:00' ), + (NULL, 30, %d, 'a:1:{i:0;s:18:\"Documentation Team\";}', '2019-12-03 11:00:00' )", + self::$user_jane_id, + self::$user_jane_id, + self::$user_ashish_id, + self::$user_ashish_id + ) ); + + reset_phpmailer_instance(); + } + + /** + * Run once after all tests are finished. + */ + public static function tear_down_after_class() { + global $wpdb; + + parent::tear_down_after_class(); + + $wpdb->query( 'DROP TABLE `bpmain_bp_xprofile_data` ' ); + } + + /** + * @covers ::remove_pledge_contributors + * @covers ::remove_contributors + */ + public function test_data_reset_once_no_active_sponsors() : void { + // Setup scenario where Jane is sponsored by two companies. + $mailer = tests_retrieve_phpmailer_instance(); + $jane = get_user_by( 'id', self::$user_jane_id ); + $jane_contribution = XProfile\get_contributor_user_data( $jane->ID ); + $tenup = get_post( self::$pledge_10up_id ); + $bluehost = get_post( self::$pledge_bluehost_id ); + $tenup_contributors = Contributor\add_pledge_contributors( $tenup->ID, array( $jane->user_login ) ); + $bluehost_contributors = Contributor\add_pledge_contributors( $bluehost->ID, array( $jane->user_login ) ); + $tenup_jane_id = $tenup_contributors[ $jane->user_login ]; + $bluehost_jane_id = $bluehost_contributors[ $jane->user_login ]; + + wp_update_post( array( + 'ID' => $tenup_jane_id, + 'post_status' => 'publish', + ) ); + wp_update_post( array( + 'ID' => $bluehost_jane_id, + 'post_status' => 'publish', + ) ); + + $bluehost_jane = get_post( $bluehost_jane_id ); + + $this->assertSame( 'publish', $bluehost->post_status ); + $this->assertSame( 'publish', $bluehost_jane->post_status ); + $this->assertSame( 40, $jane_contribution['hours_per_week'] ); + $this->assertContains( 'Core Team', $jane_contribution['team_names'] ); + + // Deactivating a pledge shouldn't trigger a data resets if they have another active sponsor. + Pledge\deactivate( $bluehost->ID, false ); + + $bluehost = get_post( $bluehost->ID ); + $bluehost_jane = get_post( $bluehost_jane->ID ); + $tenup_jane = get_post( $tenup_jane_id ); + $jane_contribution = XProfile\get_contributor_user_data( $jane->ID ); + + $this->assertSame( Pledge\DEACTIVE_STATUS, $bluehost->post_status ); + $this->assertSame( 'trash', $bluehost_jane->post_status ); + $this->assertSame( 'publish', $tenup_jane->post_status ); + $this->assertSame( 40, $jane_contribution['hours_per_week'] ); + $this->assertContains( 'Core Team', $jane_contribution['team_names'] ); + + $this->assertContains( $jane->user_email, $mailer->mock_sent[0]['to'][0] ); + $this->assertSame( "Removed from $bluehost->post_title Five for the Future pledge", $mailer->mock_sent[0]['subject'] ); + + // Once the last sponsor has been deactivated, contribution data should be reset. + Pledge\deactivate( $tenup->ID, false ); + + $tenup = get_post( $tenup->ID ); + $tenup_jane = get_post( $tenup_jane_id ); + $jane_contribution = XProfile\get_contributor_user_data( $jane->ID ); + + $this->assertSame( Pledge\DEACTIVE_STATUS, $tenup->post_status ); + $this->assertSame( 'trash', $tenup_jane->post_status ); + $this->assertSame( 0, $jane_contribution['hours_per_week'] ); + $this->assertEmpty( $jane_contribution['team_names'] ); + $this->assertContains( $jane->user_email, $mailer->mock_sent[1]['to'][0] ); + $this->assertSame( "Removed from $tenup->post_title Five for the Future pledge", $mailer->mock_sent[1]['subject'] ); + } + + /** + * @covers ::remove_pledge_contributors + * @covers ::remove_contributors + */ + public function test_data_not_reset_when_unconfirmed_sponsor() : void { + // Setup scenario where Jane was invited to join a company but didn't respond. + $mailer = tests_retrieve_phpmailer_instance(); + $jane = get_user_by( 'id', self::$user_jane_id ); + $jane_contribution = XProfile\get_contributor_user_data( $jane->ID ); + $tenup = get_post( self::$pledge_10up_id ); + $tenup_contributors = Contributor\add_pledge_contributors( $tenup->ID, array( $jane->user_login ) ); + $tenup_jane_id = $tenup_contributors[ $jane->user_login ]; + + wp_update_post( array( + 'ID' => $tenup_jane_id, + 'post_status' => 'pending', + ) ); + + $tenup_jane = get_post( $tenup_jane_id ); + + $this->assertSame( 'publish', $tenup->post_status ); + $this->assertSame( 'pending', $tenup_jane->post_status ); + $this->assertSame( 40, $jane_contribution['hours_per_week'] ); + $this->assertContains( 'Core Team', $jane_contribution['team_names'] ); + + // Deactivating a pledge shouldn't trigger a data resets if they haven't confirmed their connection to the company. + Pledge\deactivate( $tenup->ID, false ); + + $tenup = get_post( $tenup->ID ); + $tenup_jane = get_post( $tenup_jane_id ); + $jane_contribution = XProfile\get_contributor_user_data( $jane->ID ); + + $this->assertSame( Pledge\DEACTIVE_STATUS, $tenup->post_status ); + $this->assertSame( 'trash', $tenup_jane->post_status ); + $this->assertSame( 40, $jane_contribution['hours_per_week'] ); + $this->assertContains( 'Core Team', $jane_contribution['team_names'] ); + + $this->assertEmpty( $mailer->mock_sent ); + } + + /** + * @covers ::remove_contributors + */ + public function test_data_reset_when_single_contributor_removed_from_pledge() : void { + // Setup scenario where Jane and Ashish are sponsored by a company. + $mailer = tests_retrieve_phpmailer_instance(); + $jane = get_user_by( 'id', self::$user_jane_id ); + $jane_contribution = XProfile\get_contributor_user_data( $jane->ID ); + $ashish = get_user_by( 'id', self::$user_ashish_id ); + $ashish_contribution = XProfile\get_contributor_user_data( $ashish->ID ); + $tenup = get_post( self::$pledge_10up_id ); + $tenup_contributors = Contributor\add_pledge_contributors( $tenup->ID, array( $jane->user_login, $ashish->user_login ) ); + $tenup_jane_id = $tenup_contributors[ $jane->user_login ]; + $tenup_ashish_id = $tenup_contributors[ $ashish->user_login ]; + + wp_update_post( array( + 'ID' => $tenup_jane_id, + 'post_status' => 'publish', + ) ); + wp_update_post( array( + 'ID' => $tenup_ashish_id, + 'post_status' => 'publish', + ) ); + + $tenup_jane = get_post( $tenup_jane_id ); + $tenup_ashish = get_post( $tenup_ashish_id ); + + $this->assertSame( 'publish', $tenup_ashish->post_status ); + $this->assertSame( 'publish', $tenup_jane->post_status ); + $this->assertSame( 40, $jane_contribution['hours_per_week'] ); + $this->assertContains( 'Core Team', $jane_contribution['team_names'] ); + $this->assertSame( 35, $ashish_contribution['hours_per_week'] ); + $this->assertContains( 'Documentation Team', $ashish_contribution['team_names'] ); + + // Removing Jane should reset her data, but leave Ashish unaffected. + Contributor\remove_contributor( $tenup_jane_id ); + + $tenup_jane = get_post( $tenup_jane_id ); + $jane_contribution = XProfile\get_contributor_user_data( $jane->ID ); + $tenup_ashish = get_post( $tenup_ashish_id ); + $ashish_contribution = XProfile\get_contributor_user_data( $ashish->ID ); + + $this->assertSame( 'trash', $tenup_jane->post_status ); + $this->assertSame( 'publish', $tenup_ashish->post_status ); + $this->assertSame( 0, $jane_contribution['hours_per_week'] ); + $this->assertEmpty( $jane_contribution['team_names'] ); + $this->assertSame( 35, $ashish_contribution['hours_per_week'] ); + $this->assertContains( 'Documentation Team', $ashish_contribution['team_names'] ); + $this->assertCount( 1, $mailer->mock_sent ); + $this->assertContains( $jane->user_email, $mailer->mock_sent[0]['to'][0] ); + $this->assertSame( "Removed from $tenup->post_title Five for the Future pledge", $mailer->mock_sent[0]['subject'] ); + } +}