/**
  * 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;
 }