diff --git a/CRM/Contact/Page/View.php b/CRM/Contact/Page/View.php index aded97985116..4da77a244a85 100644 --- a/CRM/Contact/Page/View.php +++ b/CRM/Contact/Page/View.php @@ -306,7 +306,7 @@ public static function checkUserPermission($page, $contactID = NULL) { */ public static function setTitle($contactId, $isDeleted = FALSE) { static $contactDetails; - $displayName = $contactImage = NULL; + $contactImage = NULL; if (!isset($contactDetails[$contactId])) { list($displayName, $contactImage) = self::getContactDetails($contactId); $contactDetails[$contactId] = array( @@ -327,6 +327,15 @@ public static function setTitle($contactId, $isDeleted = FALSE) { } if ($isDeleted) { $title = "{$title}"; + $mergedTo = civicrm_api3('Contact', 'getmergedto', ['contact_id' => $contactId, 'api.Contact.get' => ['return' => 'display_name']]); + if ($mergedTo['count']) { + $mergedToContactID = $mergedTo['id']; + $mergedToDisplayName = $mergedTo['values'][$mergedToContactID]['api.Contact.get']['values'][0]['display_name']; + $title .= ' ' . ts('(This contact has been merged to %2)', [ + 1 => CRM_Utils_System::url('civicrm/contact/view', ['reset' => 1, 'cid' => $mergedToContactID]), + 2 => $mergedToDisplayName, + ]); + } } // Inline-edit places its own title on the page diff --git a/api/v3/Contact.php b/api/v3/Contact.php index 5d3ecc7af1cd..f06c4046f7bb 100644 --- a/api/v3/Contact.php +++ b/api/v3/Contact.php @@ -1162,6 +1162,149 @@ function _civicrm_api3_contact_merge_spec(&$params) { ); } +/** + * Get the ultimate contact a contact was merged to. + * + * @param array $params + * + * @return array + * API Result Array + * @throws API_Exception + */ +function civicrm_api3_contact_getmergedto($params) { + $contactID = _civicrm_api3_contact_getmergedto($params); + if ($contactID) { + $values = [$contactID => ['id' => $contactID]]; + } + else { + $values = []; + } + return civicrm_api3_create_success($values, $params); +} + +/** + * Get the contact our contact was finally merged to. + * + * If the contact has been merged multiple times the crucial parent activity will have + * wound up on the ultimate contact so we can figure out the final resting place of the + * contact with only 2 activities even if 50 merges took place. + * + * @param array $params + * + * @return int|false + */ +function _civicrm_api3_contact_getmergedto($params) { + $contactID = FALSE; + $deleteActivity = civicrm_api3('ActivityContact', 'get', [ + 'contact_id' => $params['contact_id'], + 'activity_id.activity_type_id' => 'Contact Deleted By Merge', + 'is_deleted' => 0, + 'is_test' => $params['is_test'], + 'record_type_id' => 'Activity Targets', + 'return' => ['activity_id.parent_id'], + 'sequential' => 1, + 'options' => [ + 'limit' => 1, + 'sort' => 'activity_id.activity_date_time DESC' + ], + ])['values']; + if (!empty($deleteActivity)) { + $contactID = civicrm_api3('ActivityContact', 'getvalue', [ + 'activity_id' => $deleteActivity[0]['activity_id.parent_id'], + 'record_type_id' => 'Activity Targets', + 'return' => 'contact_id', + ]); + } + return $contactID; +} + +/** + * Adjust metadata for contact_merge api function. + * + * @param array $params + */ +function _civicrm_api3_contact_getmergedto_spec(&$params) { + $params['contact_id'] = [ + 'title' => ts('ID of contact to find ultimate contact for'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => TRUE, + ]; + $params['is_test'] = [ + 'title' => ts('Get test deletions rather than live?'), + 'type' => CRM_Utils_Type::T_BOOLEAN, + 'api.default' => 0, + ]; +} + +/** + * Get the ultimate contact a contact was merged to. + * + * @param array $params + * + * @return array + * API Result Array + * @throws API_Exception + */ +function civicrm_api3_contact_getmergedfrom($params) { + $contacts = _civicrm_api3_contact_getmergedfrom($params); + return civicrm_api3_create_success($contacts, $params); +} + +/** + * Get all the contacts merged into our contact. + * + * @param array $params + * + * @return array + */ +function _civicrm_api3_contact_getmergedfrom($params) { + $activities = []; + $deleteActivities = civicrm_api3('ActivityContact', 'get', [ + 'contact_id' => $params['contact_id'], + 'activity_id.activity_type_id' => 'Contact Merged', + 'is_deleted' => 0, + 'is_test' => $params['is_test'], + 'record_type_id' => 'Activity Targets', + 'return' => 'activity_id', + ])['values']; + + foreach ($deleteActivities as $deleteActivity) { + $activities[] = $deleteActivity['activity_id']; + } + if (empty($activities)) { + return []; + } + + $activityContacts = civicrm_api3('ActivityContact', 'get', [ + 'activity_id.parent_id' => ['IN' => $activities], + 'record_type_id' => 'Activity Targets', + 'return' => 'contact_id', + ])['values']; + $contacts = []; + foreach ($activityContacts as $activityContact) { + $contacts[$activityContact['contact_id']] = ['id' => $activityContact['contact_id']]; + } + return $contacts; +} + +/** + * Adjust metadata for contact_merge api function. + * + * @param array $params + */ +function _civicrm_api3_contact_getmergedfrom_spec(&$params) { + $params['contact_id'] = [ + 'title' => ts('ID of contact to find ultimate contact for'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => TRUE, + ]; + $params['is_test'] = [ + 'title' => ts('Get test deletions rather than live?'), + 'type' => CRM_Utils_Type::T_BOOLEAN, + 'api.default' => 0, + ]; +} + /** * Adjust metadata for contact_proximity api function. * diff --git a/tests/phpunit/api/v3/ContactTest.php b/tests/phpunit/api/v3/ContactTest.php index 78c378b4ec73..3e2e9b0c2622 100644 --- a/tests/phpunit/api/v3/ContactTest.php +++ b/tests/phpunit/api/v3/ContactTest.php @@ -256,7 +256,7 @@ public function testGetMultipleContactSubTypes() { )); // create a parent - $contact = $this->callAPISuccess('contact', 'create', array( + $this->callAPISuccess('contact', 'create', array( 'email' => 'parent@example.com', 'contact_type' => 'Individual', )); @@ -3388,6 +3388,38 @@ public function testMerge() { } + /** + * Test retrieving merged contacts. + * + * The goal here is to start with a contact deleted by merged and find out the contact that is the current version of them. + */ + public function testMergedGet() { + $this->contactIDs[] = $this->individualCreate(); + $this->contactIDs[] = $this->individualCreate(); + $this->contactIDs[] = $this->individualCreate(); + $this->contactIDs[] = $this->individualCreate(); + + // First do an 'unnatural merge' - they 'like to merge into the lowest but this will mean that contact 0 merged to contact [3]. + // When the batch merge runs.... the new lowest contact is contact[1]. All contacts will merge into that contact, + // including contact[3], resulting in only 3 existing at the end. For each contact the correct answer to 'who did I eventually + // wind up being should be [1] + $this->callAPISuccess('Contact', 'merge', ['to_remove_id' => $this->contactIDs[0], 'to_keep_id' => $this->contactIDs[3]]); + + $this->callAPISuccess('Job', 'process_batch_merge', []); + foreach ($this->contactIDs as $contactID) { + if ($contactID === $this->contactIDs[1]) { + continue; + } + $result = $this->callAPIAndDocument('Contact', 'getmergedto', ['sequential' => 1, 'contact_id' => $contactID], __FUNCTION__, __FILE__); + $this->assertEquals(1, $result['count']); + $this->assertEquals($this->contactIDs[1], $result['values'][0]['id']); + } + + $result = $this->callAPIAndDocument('Contact', 'getmergedfrom', ['contact_id' => $this->contactIDs[1]], __FUNCTION__, __FILE__)['values']; + $mergedContactIds = array_merge(array_diff($this->contactIDs, [$this->contactIDs[1]])); + $this->assertEquals($mergedContactIds, array_keys($result)); + } + /** * Test merging 2 contacts with delete to trash off. *