diff --git a/includes/class-newspack-newsletters-contacts.php b/includes/class-newspack-newsletters-contacts.php index c87a526c5..29a6f4c04 100644 --- a/includes/class-newspack-newsletters-contacts.php +++ b/includes/class-newspack-newsletters-contacts.php @@ -31,10 +31,11 @@ class Newspack_Newsletters_Contacts { * } * @param string[]|false $lists Array of list IDs to subscribe the contact to. If empty or false, contact will be created but not subscribed to any lists. * @param bool $async Whether to add the contact asynchronously. Default is false. + * @param string $context Context of the update for logging purposes. * * @return array|WP_Error|true Contact data if it was added, or error otherwise. True if async. */ - public static function upsert( $contact, $lists = false, $async = false ) { + public static function upsert( $contact, $lists = false, $async = false, $context = 'Unknown' ) { if ( ! is_array( $lists ) && false !== $lists ) { $lists = [ $lists ]; } @@ -59,7 +60,7 @@ public static function upsert( $contact, $lists = false, $async = false ) { } if ( defined( 'NEWSPACK_NEWSLETTERS_ASYNC_SUBSCRIPTION_ENABLED' ) && NEWSPACK_NEWSLETTERS_ASYNC_SUBSCRIPTION_ENABLED && true === $async ) { - Newspack_Newsletters_Subscription::add_subscription_intent( $contact, $lists ); + Newspack_Newsletters_Subscription::add_subscription_intent( $contact, $lists, $context ); return true; } @@ -113,17 +114,18 @@ public static function upsert( $contact, $lists = false, $async = false ) { */ $lists = apply_filters( 'newspack_newsletters_contact_lists', $lists, $contact, $provider->service ); - return self::add_to_esp( $contact, $lists, $is_updating ); + return self::add_to_esp( $contact, $lists, $is_updating, $context ); } /** * Permanently delete a user subscription. * - * @param int $user_id User ID. + * @param int $user_id User ID. + * @param string $context Context of the update for logging purposes. * * @return bool|WP_Error Whether the contact was deleted or error. */ - public static function delete( $user_id ) { + public static function delete( $user_id, $context = 'Unknown' ) { $user = get_user_by( 'id', $user_id ); if ( ! $user ) { return new WP_Error( 'newspack_newsletters_invalid_user', __( 'Invalid user.' ) ); @@ -139,7 +141,24 @@ public static function delete( $user_id ) { if ( ! method_exists( $provider, 'delete_contact' ) ) { return new WP_Error( 'newspack_newsletters_invalid_provider_method', __( 'Provider does not support deleting user subscriptions.' ) ); } - return $provider->delete_contact( $user->user_email ); + $result = $provider->delete_contact( $user->user_email ); + + do_action( + 'newspack_log', + 'newspack_esp_sync_delete_contact', + $context, + [ + 'type' => is_wp_error( $result ) ? 'error' : 'debug', + 'data' => [ + 'provider' => $provider->service, + 'errors' => is_wp_error( $result ) ? $result->get_error_message() : [], + ], + 'user_email' => $user->user_email, + 'file' => 'newspack_esp_sync', + ] + ); + + return $result; } /** @@ -150,10 +169,11 @@ public static function delete( $user_id ) { * * @param string $email Contact email address. * @param string[] $lists Array of list IDs to subscribe the contact to. + * @param string $context Context of the update for logging purposes. * * @return bool|WP_Error Whether the contact was updated or error. */ - public static function update_lists( $email, $lists = [] ) { + public static function update_lists( $email, $lists = [], $context = 'Unknown' ) { if ( ! Newspack_Newsletters_Subscription::has_subscription_management() ) { return new WP_Error( 'newspack_newsletters_not_supported', __( 'Not supported for this provider', 'newspack-newsletters' ) ); } @@ -175,7 +195,26 @@ public static function update_lists( $email, $lists = [] ) { return false; } - $result = $provider->update_contact_lists_handling_local( $email, $lists_to_add, $lists_to_remove ); + return self::add_and_remove_lists( $email, $lists_to_add, $lists_to_remove, $context ); + } + + /** + * Add and remove a contact from lists. + * + * @param string $email Contact email address. + * @param string[] $lists_to_add Array of list IDs to subscribe the contact to. + * @param string[] $lists_to_remove Array of list IDs to remove the contact from. + * @param string $context Context of the update for logging purposes. + * + * @return bool|WP_Error Whether the contact was updated or error. + */ + public static function add_and_remove_lists( $email, $lists_to_add = [], $lists_to_remove = [], $context = 'Unknown' ) { + if ( ! Newspack_Newsletters_Subscription::has_subscription_management() ) { + return new WP_Error( 'newspack_newsletters_not_supported', __( 'Not supported for this provider', 'newspack-newsletters' ) ); + } + $provider = Newspack_Newsletters::get_service_provider(); + + $result = $provider->update_contact_lists_handling_local( $email, $lists_to_add, $lists_to_remove, $context ); /** * Fires after a contact's lists are updated. @@ -185,8 +224,26 @@ public static function update_lists( $email, $lists = [] ) { * @param string[] $lists_to_add Array of list IDs to subscribe the contact to. * @param string[] $lists_to_remove Array of list IDs to remove the contact from. * @param bool|WP_Error $result True if the contact was updated or error if failed. + * @param string $context Context of the update for logging purposes. */ - do_action( 'newspack_newsletters_update_contact_lists', $provider->service, $email, $lists_to_add, $lists_to_remove, $result ); + do_action( 'newspack_newsletters_update_contact_lists', $provider->service, $email, $lists_to_add, $lists_to_remove, $result, $context ); + + do_action( + 'newspack_log', + 'newspack_esp_sync_update_lists', + $context, + [ + 'type' => is_wp_error( $result ) ? 'error' : 'debug', + 'data' => [ + 'provider' => $provider->service, + 'lists_to_add' => $lists_to_add, + 'lists_to_remove' => $lists_to_remove, + 'errors' => is_wp_error( $result ) ? $result->get_error_messages() : [], + ], + 'user_email' => $email, + 'file' => 'newspack_esp_sync', + ] + ); return $result; } @@ -195,13 +252,14 @@ public static function update_lists( $email, $lists = [] ) { * Internal method to add a contact to lists. Should be called by the * `add_contact` method or `handle_async_subscribe` for the async strategy. * - * @param array $contact Contact information. - * @param array $lists Array of list IDs to subscribe the contact to. - * @param bool $is_updating Whether the contact is being updated. If false, the contact is being created. + * @param array $contact Contact information. + * @param array $lists Array of list IDs to subscribe the contact to. + * @param bool $is_updating Whether the contact is being updated. If false, the contact is being created. + * @param string $context Context of the update for logging purposes. * * @return array|WP_Error Contact data if it was added, or error otherwise. */ - private static function add_to_esp( $contact, $lists = [], $is_updating = false ) { + private static function add_to_esp( $contact, $lists = [], $is_updating = false, $context = 'Unknown' ) { $provider = Newspack_Newsletters::get_service_provider(); $errors = new WP_Error(); $result = []; @@ -247,8 +305,26 @@ private static function add_to_esp( $contact, $lists = [], $is_updating = false * @param string[]|false $lists Array of list IDs to subscribe the contact to. * @param array|WP_Error $result Array with data if the contact was added or error if failed. * @param bool $is_updating Whether the contact is being updated. If false, the contact is being created. + * @param string $context Context of the update for logging purposes. */ - do_action( 'newspack_newsletters_add_contact', $provider->service, $contact, $lists, $result, $is_updating ); + do_action( 'newspack_newsletters_add_contact', $provider->service, $contact, $lists, $result, $is_updating, $context ); + + do_action( + 'newspack_log', + 'newspack_esp_sync_upsert_contact', + $context, + [ + 'type' => $errors->has_errors() ? 'error' : 'debug', + 'data' => [ + 'provider' => $provider->service, + 'lists' => $lists, + 'contact' => $contact, + 'errors' => $errors->get_error_messages(), + ], + 'user_email' => $contact['email'], + 'file' => 'newspack_esp_sync', + ] + ); if ( $errors->has_errors() ) { return $errors; diff --git a/includes/class-newspack-newsletters-subscription.php b/includes/class-newspack-newsletters-subscription.php index 8720e095c..85ec0422a 100644 --- a/includes/class-newspack-newsletters-subscription.php +++ b/includes/class-newspack-newsletters-subscription.php @@ -357,18 +357,19 @@ public static function get_contact_data( $email_address, $return_details = false */ public static function add_contact( $contact, $lists = false, $async = false ) { _deprecated_function( __METHOD__, '2.21', 'Newspack_Newsletters_Contacts::upsert' ); - return Newspack_Newsletters_Contacts::upsert( $contact, $lists, $async ); + return Newspack_Newsletters_Contacts::upsert( $contact, $lists, $async, 'deprecated' ); } /** * Register a subscription intent and dispatches a async request to process it. * - * @param array $contact Contact information. - * @param array $lists Array of list IDs to subscribe the contact to. + * @param array $contact Contact information. + * @param array $lists Array of list IDs to subscribe the contact to. + * @param string $context Context of the update for logging purposes. * * @return int|WP_Error Subscription intent ID or error. */ - public static function add_subscription_intent( $contact, $lists ) { + public static function add_subscription_intent( $contact, $lists, $context = '' ) { $intent_id = \wp_insert_post( [ 'post_type' => self::SUBSCRIPTION_INTENT_CPT, @@ -377,6 +378,7 @@ public static function add_subscription_intent( $contact, $lists ) { 'contact' => $contact, 'lists' => $lists, 'errors' => [], + 'context' => $context, ], ] ); @@ -422,6 +424,7 @@ private static function get_subscription_intent( $intent_id_or_post ) { 'contact' => get_post_meta( $intent->ID, 'contact', true ), 'lists' => get_post_meta( $intent->ID, 'lists', true ), 'errors' => get_post_meta( $intent->ID, 'errors', true ), + 'context' => get_post_meta( $intent->ID, 'context', true ), ]; } @@ -473,7 +476,8 @@ public static function process_subscription_intents( $intent_id = null ) { $contact = $intent['contact']; $email = $contact['email']; $lists = $intent['lists']; - $result = Newspack_Newsletters_Contacts::upsert( $contact, $lists, false ); + $context = $intent['context']; + $result = Newspack_Newsletters_Contacts::upsert( $contact, $lists, false, $context . ' (ASYNC)' ); $user = get_user_by( 'email', $email ); if ( \is_wp_error( $result ) ) { @@ -576,13 +580,14 @@ public static function newspack_registered_reader( $email, $authenticate, $user_ // Adding is actually upserting, so no need to check if the hook is called for an existing user. try { - self::add_contact( + Newspack_Newsletters_Contacts::upsert( [ 'email' => $email, 'metadata' => $metadata, ], $lists, - true // Async. + true, // Async. + 'Reader registration hook on Newsletters plugin' ); } catch ( \Exception $e ) { // Avoid breaking the registration process. @@ -603,7 +608,7 @@ public static function newspack_registered_reader( $email, $authenticate, $user_ */ private static function update_contact_lists( $email, $lists = [] ) { _deprecated_function( __METHOD__, '2.21', 'Newspack_Newsletters_Contacts::update_lists' ); - return Newspack_Newsletters_Contacts::update_lists( $email, $lists ); + return Newspack_Newsletters_Contacts::update_lists( $email, $lists, 'deprecated' ); } /** @@ -1001,7 +1006,7 @@ public static function process_subscription_update() { } else { $email = get_userdata( get_current_user_id() )->user_email; $lists = isset( $_POST['lists'] ) ? array_map( 'sanitize_text_field', $_POST['lists'] ) : []; - $result = Newspack_Newsletters_Contacts::update_lists( $email, $lists ); + $result = Newspack_Newsletters_Contacts::update_lists( $email, $lists, 'User updated their subscriptions on My Account page' ); if ( is_wp_error( $result ) ) { wc_add_notice( $result->get_error_message(), 'error' ); } elseif ( false === $result ) { diff --git a/includes/plugins/class-woocommerce-memberships.php b/includes/plugins/class-woocommerce-memberships.php index aeffd1c69..b52b1ce8e 100644 --- a/includes/plugins/class-woocommerce-memberships.php +++ b/includes/plugins/class-woocommerce-memberships.php @@ -10,6 +10,7 @@ use Newspack\Newsletters\Subscription_List; use Newspack\Newsletters\Subscription_Lists; use Newspack_Newsletters; +use Newspack_Newsletters_Contacts; use Newspack_Newsletters_Logger; defined( 'ABSPATH' ) || exit; @@ -206,8 +207,6 @@ public static function remove_user_from_lists( $user_membership ) { return; } - $provider = Newspack_Newsletters::get_service_provider(); - /** * Check if the user is already in one of the lists. If they are, store it. * @@ -225,10 +224,8 @@ public static function remove_user_from_lists( $user_membership ) { self::update_user_lists_on_deactivation( $user->ID, $user_membership->get_id(), $existing_lists ); - if ( ! empty( $provider ) ) { - $provider->update_contact_lists_handling_local( $user_email, [], $lists_to_remove ); - Newspack_Newsletters_Logger::log( 'Reader ' . $user_email . ' removed from the following lists: ' . implode( ', ', $lists_to_remove ) ); - } + Newspack_Newsletters_Contacts::add_and_remove_lists( $user_email, [], $lists_to_remove, 'Removing user from lists tied to Memberships being marked as inactive' ); + Newspack_Newsletters_Logger::log( 'Reader ' . $user_email . ' removed from the following lists: ' . implode( ', ', $lists_to_remove ) ); } /** @@ -336,8 +333,7 @@ public static function add_user_to_lists( $plan, $args ) { return; } - $provider = Newspack_Newsletters::get_service_provider(); - $result = $provider->update_contact_lists_handling_local( $user_email, $lists_to_add ); + $result = Newspack_Newsletters_Contacts::add_and_remove_lists( $user_email, $lists_to_add, [], 'Adding user to lists tied to Memberships being marked as active' ); if ( is_wp_error( $result ) ) { Newspack_Newsletters_Logger::log( 'An error occured while updating lists for ' . $user_email . ': ' . $result->get_error_message() ); diff --git a/includes/service-providers/class-newspack-newsletters-service-provider.php b/includes/service-providers/class-newspack-newsletters-service-provider.php index 2f0768539..f19a48091 100644 --- a/includes/service-providers/class-newspack-newsletters-service-provider.php +++ b/includes/service-providers/class-newspack-newsletters-service-provider.php @@ -491,12 +491,12 @@ public function add_contact_handling_local_list( $contact, $list_id ) { try { $list = Subscription_List::from_form_id( $list_id ); if ( ! $list->is_configured_for_provider( $this->service ) ) { - return new WP_Error( 'List not properly configured for the provider' ); + return new WP_Error( "List $list_id not properly configured for the provider" ); } $list_settings = $list->get_provider_settings( $this->service ); return $this->add_esp_local_list_to_contact( $contact['email'], $list_settings['tag_id'], $list_settings['list'] ); } catch ( \InvalidArgumentException $e ) { - return new WP_Error( 'List not found' ); + return new WP_Error( "List $list_id not found" ); } } } @@ -509,15 +509,16 @@ public function add_contact_handling_local_list( $contact, $list_id ) { * @param string $email Contact email address. * @param string[] $lists_to_add Array of list IDs to subscribe the contact to. * @param string[] $lists_to_remove Array of list IDs to remove the contact from. + * @param string $context The context in which the update is being performed. For logging purposes. * * @return true|WP_Error True if the contact was updated or error. */ - public function update_contact_lists_handling_local( $email, $lists_to_add = [], $lists_to_remove = [] ) { + public function update_contact_lists_handling_local( $email, $lists_to_add = [], $lists_to_remove = [], $context = 'Unknown' ) { $contact = $this->get_contact_data( $email ); if ( is_wp_error( $contact ) ) { // Create contact. - // Use Newspack_Newsletters_Subscription::add_contact to trigger hooks and call add_contact_handling_local_list if needed. - $result = Newspack_Newsletters_Subscription::add_contact( [ 'email' => $email ], $lists_to_add ); + // Use Newspack_Newsletters_Contacts::upsert to trigger hooks and call add_contact_handling_local_list if needed. + $result = Newspack_Newsletters_Contacts::upsert( [ 'email' => $email ], $lists_to_add, false, $context ); if ( is_wp_error( $result ) ) { return $result; } diff --git a/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp-notes.php b/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp-notes.php new file mode 100644 index 000000000..4b39051c0 --- /dev/null +++ b/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp-notes.php @@ -0,0 +1,146 @@ +api_key() ); + foreach ( $audience_ids as $audience_id ) { + $subscriber_hash = Mailchimp::subscriberHash( $email ); + $api->post( + sprintf( 'lists/%s/members/%s/notes', $audience_id, $subscriber_hash ), + [ + 'note' => $note, + ] + ); + } + } + + /** + * Extracts the Audience IDs from the list form IDs. + * + * Given a list of lists, we will parse in which audience they belong. + * + * @param array $lists An array of lists IDs (as they are used in our forms. aka form_id). A list can be an Audience, a tag, a group or a local list. + * @return array An array of Audience IDs. + */ + private static function extract_audience_ids( $lists ) { + $mc = Newspack_Newsletters_Mailchimp::instance(); + $audience_ids = []; + foreach ( $lists as $list ) { + if ( Subscription_List::is_local_form_id( $list ) ) { + continue; + } + $remote_list_details = $mc->maybe_extract_group_or_tag_list( $list ); + if ( false !== $remote_list_details ) { + $audience_ids[] = $remote_list_details['list_id']; + continue; + } + $audience_ids[] = $list; + } + return $audience_ids; + } + + /** + * Handles the upsert of a contact. + * + * @param string $provider The provider name. + * @param array $contact { + * Contact information. + * + * @type string $email Contact email address. + * @type string $name Contact name. Optional. + * @type string[] $metadata Contact additional metadata. Optional. + * } + * @param string[]|false $lists Array of list IDs to subscribe the contact to. + * @param array|WP_Error $result Array with data if the contact was added or error if failed. + * @param bool $is_updating Whether the contact is being updated. If false, the contact is being created. + * @param string $context Context of the update for logging purposes. + * @return void + */ + public static function handle_upsert( $provider, $contact, $lists, $result, $is_updating, $context ) { + if ( 'mailchimp' !== $provider ) { + return; + } + + $lists_string = implode( ', ', $lists ); + $message = sprintf( + /* translators: 1: email address, 2: list IDs */ + __( 'Contact updated by Newspack from site %1$s. Context: %2$s. Lists added: %3$s', 'newspack-newsletters' ), + get_site_url(), + $context, + $lists_string + ); + + self::add_note( $contact['email'], $lists, $message ); + } + + /** + * Handles the update of lists for a contact. + * + * Note that we will add a note even to the lists (audiences) that the contact is being removed from. + * + * @param string $provider The provider name. + * @param string $email Contact email address. + * @param string[] $lists_to_add Array of list IDs to subscribe the contact to. + * @param string[] $lists_to_remove Array of list IDs to remove the contact from. + * @param bool|WP_Error $result True if the contact was updated or error if failed. + * @param string $context Context of the update for logging purposes. + * @return void + */ + public static function handle_update_lists( $provider, $email, $lists_to_add, $lists_to_remove, $result, $context ) { + if ( 'mailchimp' !== $provider ) { + return; + } + + $lists_to_add_string = implode( ', ', $lists_to_add ); + $lists_to_remove_string = implode( ', ', $lists_to_remove ); + + $message = sprintf( + /* translators: 1: email address, 2: lists added, 3: lists removed */ + __( 'Contact updated by Newspack. Context: %1$s. Lists added: %2$s. Lists removed: %3$s', 'newspack-newsletters' ), + $context, + $lists_to_add_string ? $lists_to_add_string : 'none', + $lists_to_remove_string ? $lists_to_remove_string : 'none' + ); + + self::add_note( $email, array_merge( $lists_to_add, $lists_to_remove ), $message ); + } +} + +Newspack_Newsletters_Mailchimp_Notes::init(); diff --git a/newspack-newsletters.php b/newspack-newsletters.php index c5137ea52..3bcb91d98 100644 --- a/newspack-newsletters.php +++ b/newspack-newsletters.php @@ -40,6 +40,7 @@ require_once NEWSPACK_NEWSLETTERS_PLUGIN_FILE . '/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp-controller.php'; require_once NEWSPACK_NEWSLETTERS_PLUGIN_FILE . '/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp-cached-data.php'; require_once NEWSPACK_NEWSLETTERS_PLUGIN_FILE . '/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp-usage-reports.php'; +require_once NEWSPACK_NEWSLETTERS_PLUGIN_FILE . '/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp-notes.php'; require_once NEWSPACK_NEWSLETTERS_PLUGIN_FILE . '/includes/service-providers/constant_contact/class-newspack-newsletters-constant-contact.php'; require_once NEWSPACK_NEWSLETTERS_PLUGIN_FILE . '/includes/service-providers/constant_contact/class-newspack-newsletters-constant-contact-controller.php'; require_once NEWSPACK_NEWSLETTERS_PLUGIN_FILE . '/includes/service-providers/constant_contact/class-newspack-newsletters-constant-contact-sdk.php'; diff --git a/src/blocks/subscribe/index.php b/src/blocks/subscribe/index.php index 298f6b88c..5cd0a1bd0 100644 --- a/src/blocks/subscribe/index.php +++ b/src/blocks/subscribe/index.php @@ -505,7 +505,8 @@ function process_form() { 'metadata' => $metadata, ], $lists, - true // Async. + true, // Async. + 'User subscribed via Newsletters Subscription block' ); /** diff --git a/tests/mocks/class-mailchimp-mock.php b/tests/mocks/class-mailchimp-mock.php index 7d940b6d2..253fd82e9 100644 --- a/tests/mocks/class-mailchimp-mock.php +++ b/tests/mocks/class-mailchimp-mock.php @@ -193,5 +193,15 @@ function( $tag_name ) { 'status' => 200, ]; } + + /** + * Get the subscriber hash. + * + * @param string $email Email address. + * @return string + */ + public static function subscriberHash( $email ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return md5( strtolower( $email ) ); + } } MailChimp::init(); diff --git a/tests/test-subscription-attempts.php b/tests/test-subscription-attempts.php index 3b6a09ba6..f83def655 100644 --- a/tests/test-subscription-attempts.php +++ b/tests/test-subscription-attempts.php @@ -34,14 +34,14 @@ public function test_subscription_attempts_add_and_update() { self::assertEquals( implode( ',', $lists ), $result->list_ids ); $lists_added = [ 'list3', 'list4' ]; - do_action( 'newspack_newsletters_update_contact_lists', 'some_esp', $contact['email'], $lists_added, [], true ); + do_action( 'newspack_newsletters_update_contact_lists', 'some_esp', $contact['email'], $lists_added, [], true, 'test' ); $result = Newspack_Newsletters_Subscription_Attempts::get_by_email( $contact['email'] ); $lists_expected = array_merge( $lists, $lists_added ); self::assertEquals( implode( ',', $lists_expected ), $result->list_ids ); $lists_removed = [ 'list1', 'list3' ]; - do_action( 'newspack_newsletters_update_contact_lists', 'some_esp', $contact['email'], [], $lists_removed, true ); + do_action( 'newspack_newsletters_update_contact_lists', 'some_esp', $contact['email'], [], $lists_removed, true, 'test' ); $result = Newspack_Newsletters_Subscription_Attempts::get_by_email( $contact['email'] ); $lists_expected = [ 'list2', 'list4' ];