/** * Create fixture in CiviCRM. */ public function setUp() { if (static::$fixture_should_be_reset) { static::createCiviCrmFixtures(); } static::$fixture_should_be_reset = TRUE; // Ensure this is at its default state. CRM_Mailchimp_Utils::$post_hook_enabled = TRUE; }
/** * Sugar function for adjusting fixture: uses CiviCRM API to delete all * GroupContact records between the contact and the group specified. * * @param array $contact Set to static::$civicrm_contact_{1,2} * @param int $group_id Set to * static::$civicrm_group_id_interest_{1,2} */ public function deleteGroup($contact, $group_id, $disable_post_hooks = FALSE) { if ($disable_post_hooks) { $original_state = CRM_Mailchimp_Utils::$post_hook_enabled; CRM_Mailchimp_Utils::$post_hook_enabled = FALSE; } $result = civicrm_api3('GroupContact', 'delete', ['group_id' => $group_id, 'contact_id' => $contact['contact_id']]); if ($disable_post_hooks) { CRM_Mailchimp_Utils::$post_hook_enabled = $original_state; } return $result; }
/** * Validate and process the request. * * This is separated from the run() method for testing purposes. * * This method serves as a router to other methods named after the type of * webhook we're called with. * * Methods may return data for mailchimp, or may throw RuntimeException * objects, the error code of which will be used for the response. * So you can throw a `RuntimeException("Invalid webhook configuration", 500);` * to tell mailchimp the webhook failed, but you can equally throw a * `RuntimeException("soft fail", 200)` which will not tell Mailchimp there * was any problem. Mailchimp retries if there was a problem. * * If an exception is thrown, it is logged. @todo where? * * @return array with two values: $response_code, $response_object. */ public function processRequest($expected_key, $key, $request_data) { // Check CMS's permission for (presumably) anonymous users. if (CRM_Core_Config::singleton()->userPermissionClass->isModulePermissionSupported() && !CRM_Mailchimp_Permission::check('allow webhook posts')) { throw new RuntimeException("Missing allow webhook posts permission.", 500); } // Check the 2 keys exist and match. if (!$key || !$expected_key || $key != $expected_key) { throw new RuntimeException("Invalid security key.", 500); } if (empty($request_data['data']['list_id']) || empty($request_data['type']) || !in_array($request_data['type'], ['subscribe', 'unsubscribe', 'profile', 'upemail', 'cleaned'])) { // We are not programmed to respond to this type of request. // But maybe Mailchimp introduced something new, so we'll just say OK. throw new RuntimeException("Missing or invalid data in request: " . json_encode($request_data), 200); } $method = $request_data['type']; // Check list config at Mailchimp. $list_id = $request_data['data']['list_id']; $api = CRM_Mailchimp_Utils::getMailchimpApi(); $result = $api->get("/lists/{$list_id}/webhooks")->data->webhooks; $url = CRM_Mailchimp_Utils::getWebhookUrl(); // Find our webhook and check for a particularly silly configuration. foreach ($result as $webhook) { if ($webhook->url == $url) { if ($webhook->sources->api) { // To continue could cause a nasty loop. throw new RuntimeException("The list '{$list_id}' is not configured correctly at Mailchimp. It has the 'API' source set so processing this using the API could cause a loop.", 500); } } } // Disable post hooks. We're updating *from* Mailchimp so we don't want // to fire anything *at* Mailchimp. CRM_Mailchimp_Utils::$post_hook_enabled = FALSE; // Pretty much all the request methods use these: $this->sync = new CRM_Mailchimp_Sync($request_data['data']['list_id']); $this->request_data = $request_data['data']; // Call the appropriate handler method. CRM_Mailchimp_Utils::checkDebug("Webhook: {$method} with request data: " . json_encode($request_data)); $this->{$method}(); // re-set the post hooks. CRM_Mailchimp_Utils::$post_hook_enabled = TRUE; // Return OK response. return [200, NULL]; }
/** * Test new mailchimp contacts added to CiviCRM. * * Add contact1 and subscribe, then delete contact 1 from CiviCRM, then do a * pull. This should result in contact 1 being re-created with all their * details. * * WARNING if this test fails at a particular place it messes up the fixture, * but that's unlikely. * * @group pull * */ public function testPullAddsContact() { // Give contact 1 an interest. $this->joinGroup(static::$civicrm_contact_1, static::$civicrm_group_id_interest_1, TRUE); // Add contact 1 to membership group thus subscribing them at Mailchimp. $this->joinMembershipGroup(static::$civicrm_contact_1); // Delete contact1 from CiviCRM // We have to ensure no post hooks are fired, so we disable the API. CRM_Mailchimp_Utils::$post_hook_enabled = FALSE; $result = civicrm_api3('Contact', 'delete', ['id' => static::$civicrm_contact_1['contact_id'], 'skip_undelete' => 1]); static::$civicrm_contact_1['contact_id'] = 0; CRM_Mailchimp_Utils::$post_hook_enabled = TRUE; try { // Collect data from Mailchimp and CiviCRM. $sync = new CRM_Mailchimp_Sync(static::$test_list_id); $sync->collectCiviCrm('pull'); $sync->collectMailchimp('pull'); $matches = $sync->matchMailchimpMembersToContacts(); $this->assertEquals(['bySubscribers' => 0, 'byUniqueEmail' => 0, 'byNameEmail' => 0, 'bySingle' => 0, 'totalMatched' => 0, 'newContacts' => 1, 'failures' => 0], $matches); // Remove in-sync things (nothing should be in sync) $in_sync = $sync->removeInSync('pull'); $this->assertEquals(0, $in_sync); // Make changes in Civi. $stats = $sync->updateCiviFromMailchimp(); $this->assertEquals(['created' => 1, 'joined' => 0, 'in_sync' => 0, 'removed' => 0, 'updated' => 0], $stats); // Ensure expected change was made. $result = civicrm_api3('Contact', 'getsingle', ['email' => static::$civicrm_contact_1['email'], 'first_name' => static::$civicrm_contact_1['first_name'], 'last_name' => static::$civicrm_contact_1['last_name'], 'return' => 'group']); // If that didn't throw an exception, the contact was created. // Store the new contact id in the fixture to enable clearup. static::$civicrm_contact_1['contact_id'] = (int) $result['contact_id']; // Check they're in the membership group. $in_groups = CRM_Mailchimp_Utils::getGroupIds($result['groups'], $sync->group_details); $this->assertContains(static::$civicrm_group_id_membership, $in_groups, "New contact was not in membership group, but should be."); $this->assertContains(static::$civicrm_group_id_interest_1, $in_groups, "New contact was not in interest group 1, but should be."); } catch (CRM_Mailchimp_Exception $e) { // Spit out request and response for debugging. print "Request:\n"; print_r($e->request); print "Response:\n"; print_r($e->response); // re-throw exception. throw $e; } }
/** * "Pull" sync. * * Updates CiviCRM from Mailchimp using the tmp_mailchimp_push_[cm] tables. * * It is assumed that collections (in 'pull' mode) and `removeInSync` have * already run. * * 1. Loop the full tmp_mailchimp_push_m table: * * 1. Contact identified by collectMailchimp()? * - Yes: update name if different. * - No: Create or find-and-update the contact. * * 2. Check for changes in groups; record what needs to be changed for a * batch update. * * 2. Batch add/remove contacts from groups. * * @return array With the following keys: * * - created: was in MC not CiviCRM so a new contact was created * - joined : email matched existing contact that was joined to the membership * group. * - in_sync: was in MC and on membership group already. * - removed: was not in MC but was on membership group, so removed from * membership group. * - updated: No. in_sync or joined contacts that were updated. * * The initials of these categories c, j, i, r correspond to this diagram: * * From Mailchimp: ************ * From CiviCRM : ******** * Result : ccccjjjjiiiirrrr * * Of the contacts known in both systems (j, i) we also record how many were * updated (e.g. name, interests). * * Work in pass 1: * * - create|find * - join * - update names * - update interests * * Work in pass 2: * * - remove */ public function updateCiviFromMailchimp() { // Ensure posthooks don't trigger while we make GroupContact changes. CRM_Mailchimp_Utils::$post_hook_enabled = FALSE; // This is a functional variable, not a stats. one $changes = ['removals' => [], 'additions' => []]; CRM_Mailchimp_Utils::checkDebug("updateCiviFromMailchimp for group #{$this->membership_group_id}"); // Stats. $stats = ['created' => 0, 'joined' => 0, 'in_sync' => 0, 'removed' => 0, 'updated' => 0]; // all Mailchimp table *except* titanics: where the contact matches multiple // contacts in CiviCRM. $dao = CRM_Core_DAO::executeQuery("SELECT m.*,\n c.contact_id c_contact_id,\n c.interests c_interests, c.first_name c_first_name, c.last_name c_last_name\n FROM tmp_mailchimp_push_m m\n LEFT JOIN tmp_mailchimp_push_c c ON m.cid_guess = c.contact_id\n WHERE m.cid_guess IS NOT NULL\n ;"); // Create lookup hash to map Mailchimp Interest Ids to CiviCRM Groups. $interest_to_group_id = []; foreach ($this->interest_group_details as $group_id => $details) { $interest_to_group_id[$details['interest_id']] = $group_id; } // Loop records found at Mailchimp, creating/finding contacts in CiviCRM. while ($dao->fetch()) { $existing_contact_changed = FALSE; if (!empty($dao->cid_guess)) { // Matched existing contact: result: joined or in_sync $contact_id = $dao->cid_guess; if ($dao->c_contact_id) { // Contact is already in the membership group. $stats['in_sync']++; } else { // Contact needs joining to the membership group. $stats['joined']++; if (!$this->dry_run) { // Live. $changes['additions'][$this->membership_group_id][] = $contact_id; } else { // Dry Run. CRM_Mailchimp_Utils::checkDebug("Would add existing contact to membership group. Email: {$dao->email} Contact Id: {$dao->cid_guess}"); } } // Update the first name and last name of the contacts we know // if needed and making sure we don't overwrite // something with nothing. See issue #188. $edits = static::updateCiviFromMailchimpContactLogic(['first_name' => $dao->first_name, 'last_name' => $dao->last_name], ['first_name' => $dao->c_first_name, 'last_name' => $dao->c_last_name]); if ($edits) { if (!$this->dry_run) { // There are changes to be made so make them now. civicrm_api3('Contact', 'create', ['id' => $contact_id] + $edits); } else { // Dry run. CRM_Mailchimp_Utils::checkDebug("Would update CiviCRM contact {$dao->cid_guess} " . (empty($edits['first_name']) ? '' : "First name from {$dao->c_first_name} to {$dao->first_name} ") . (empty($edits['last_name']) ? '' : "Last name from {$dao->c_last_name} to {$dao->last_name} ")); } $existing_contact_changed = TRUE; } } else { // Contact does not exist, create a new one. if (!$this->dry_run) { // Live: $result = civicrm_api3('Contact', 'create', ['contact_type' => 'Individual', 'first_name' => $dao->first_name, 'last_name' => $dao->last_name, 'email' => $dao->email, 'sequential' => 1]); $contact_id = $result['values'][0]['id']; $changes['additions'][$this->membership_group_id][] = $contact_id; } else { // Dry Run: CRM_Mailchimp_Utils::checkDebug("Would create new contact with email: {$dao->email}, name: {$dao->first_name} {$dao->last_name}"); $contact_id = 'dry-run'; } $stats['created']++; } // Do interests need updating? if ($dao->c_interests && $dao->c_interests == $dao->interests) { // Nothing to change. } else { // Unpack the interests reported by MC $mc_interests = unserialize($dao->interests); if ($dao->c_interests) { // Existing contact. $existing_contact_changed = TRUE; $civi_interests = unserialize($dao->c_interests); } else { // Newly created contact is not in any interest groups. $civi_interests = []; } // Discover what needs changing to bring CiviCRM inline with Mailchimp. foreach ($mc_interests as $interest => $member_has_interest) { if ($member_has_interest && empty($civi_interests[$interest])) { // Member is interested in something, but CiviCRM does not know yet. if (!$this->dry_run) { $changes['additions'][$interest_to_group_id[$interest]][] = $contact_id; } else { CRM_Mailchimp_Utils::checkDebug("Would add CiviCRM contact {$dao->cid_guess} to interest group " . $interest_to_group_id[$interest]); } } elseif (!$member_has_interest && !empty($civi_interests[$interest])) { // Member is not interested in something, but CiviCRM thinks it is. if (!$this->dry_run) { $changes['removals'][$interest_to_group_id[$interest]][] = $contact_id; } else { CRM_Mailchimp_Utils::checkDebug("Would remove CiviCRM contact {$dao->cid_guess} from interest group " . $interest_to_group_id[$interest]); } } } } if ($existing_contact_changed) { $stats['updated']++; } } // And now, what if a contact is not in the Mailchimp list? // We must remove them from the membership group. // Accademic interest (#188): what's faster, this or a 'WHERE NOT EXISTS' // construct? $dao = CRM_Core_DAO::executeQuery("\n SELECT c.contact_id\n FROM tmp_mailchimp_push_c c\n LEFT OUTER JOIN tmp_mailchimp_push_m m ON m.cid_guess = c.contact_id\n WHERE m.email IS NULL;\n "); // Collect the contact_ids that need removing from the membership group. while ($dao->fetch()) { if (!$this->dry_run) { $changes['removals'][$this->membership_group_id][] = $dao->contact_id; } else { CRM_Mailchimp_Utils::checkDebug("Would remove CiviCRM contact {$dao->contact_id} from membership group - no longer subscribed at Mailchimp."); } $stats['removed']++; } if (!$this->dry_run) { // Log group contacts which are going to be added/removed to/from CiviCRM CRM_Mailchimp_Utils::checkDebug('Mailchimp $changes', $changes); // Make the changes. if ($changes['additions']) { // We have some contacts to add into groups... foreach ($changes['additions'] as $groupID => $contactIDs) { CRM_Contact_BAO_GroupContact::addContactsToGroup($contactIDs, $groupID, 'Admin', 'Added'); } } if ($changes['removals']) { // We have some contacts to add into groups... foreach ($changes['removals'] as $groupID => $contactIDs) { CRM_Contact_BAO_GroupContact::removeContactsFromGroup($contactIDs, $groupID, 'Admin', 'Removed'); } } } // Re-enable the post hooks. CRM_Mailchimp_Utils::$post_hook_enabled = TRUE; return $stats; }