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.
*