contactcats/Civi/ContactCats/Processor.php
2025-03-03 11:53:47 +00:00

308 lines
12 KiB
PHP

<?php
namespace Civi\ContactCats;
use CRM_Contactcats_ExtensionUtil as E;
use Civi;
use Civi\Api4\Activity;
use Civi\Api4\ContactCategoryDefinition;
use Civi\Api4\Group;
use Civi\Api4\SavedSearch;
use CRM_Core_DAO;
use CRM_Core_Exception;
class Processor {
protected array $categories = [];
protected array $groupDetails = [];
protected array $searchDetails = [];
protected ?int $contact_id = NULL;
public function __construct(?int $contact_id = NULL) {
$this->contact_id = $contact_id;
$this->categories = ContactCategoryDefinition::get(FALSE)
->addOrderBy('execution_order')
->execute()->indexBy('id')->getArrayCopy();
// Check the last item is the 'default' one.
if ((end($this->categories)['search_type'] ?? '') !== 'default') {
throw new CRM_Core_Exception("ContactCategoryDefinition unconfigured; no default category.");
}
// Identify groups and searches used by definitions.
$groupIDs = $searchIDs = [];
foreach ($this->categories as $cat) {
if ($cat['search_type'] === 'group') {
$groupIDs[] = (int) $cat['search_data']['group_id'];
}
elseif ($cat['search_type'] === 'search') {
$searchIDs[] = (int) $cat['search_data']['saved_search_id'];
}
}
// Load group details; ensure all groups still exist.
$this->groupDetails = [];
if ($groupIDs) {
$this->groupDetails = Group::get(FALSE)
->addWhere('id', 'IN', $groupIDs)
->execute()->indexBy('id')->getArrayCopy();
foreach ($this->categories as $cat) {
if ($cat['search_type'] === 'group') {
if (!isset($this->groupDetails[$cat['search_data']['group_id'] ?? 0])) {
throw new CRM_Core_Exception("ContactCategoryDefinition $cat[id] $cat[label] references invalid Group: " . $cat['search_data']['group_id']);
}
}
}
}
// Load all searches
$this->searchDetails = [];
if ($searchIDs) {
$this->searchDetails = SavedSearch::get(FALSE)
->addWhere('id', 'IN', $searchIDs)
->execute()->indexBy('id')->getArrayCopy();
foreach ($this->categories as $cat) {
if ($cat['search_type'] === 'search') {
if (!isset($this->searchDetails[$cat['search_data']['saved_search_id'] ?? 0])) {
throw new CRM_Core_Exception("ContactCategoryDefinition $cat[id] $cat[label] references invalid Search: " . $cat['search_data']['saved_search_id']);
}
$search = $this->searchDetails[$cat['search_data']['saved_search_id']];
if (!isset($search['api_params']) || !isset($search['api_entity'])) {
throw new CRM_Core_Exception("ContactCategoryDefinition $cat[id] $cat[label] uses search $search[id] which does not look appropriate.");
}
}
}
}
// TODO: check things like searches that group by contact_id
}
/**
* Main calling point.
*/
public function run() {
$this->refreshSmartGroups();
$summary = [];
\CRM_Core_Transaction::create()->run(function($tx) use (&$summary) {
$this->resetTable();
foreach ($this->categories as $cat) {
if ($cat['search_type'] === 'group') {
$this->assignCategoryFromGroup($cat);
}
elseif ($cat['search_type'] === 'search') {
$this->assignCategoryFromSearch($cat);
}
elseif ($cat['search_type'] === 'default') {
$this->assignDefaultCategory($cat);
}
else {
throw new CRM_Core_DAO("Invalid search_type: $cat[search_type] for category $cat[id]");
}
// future...
}
$summary = $this->createChangeActivities();
});
return $summary;
}
/**
* Creates change activities and actually updates the current category data.
*/
protected function createChangeActivities() {
$singleContactClause = $this->contact_id ? "AND id = $this->contact_id" : "";
Civi::log()->debug("Calculate changes", ['=' => 'timed', '=start' => "changes"]);
$changes = CRM_Core_DAO::executeQuery(<<<SQL
SELECT id, category_definition_id, next_category
FROM civicrm_contact_category
WHERE (category_definition_id IS NULL OR next_category <> category_definition_id) $singleContactClause
ORDER BY category_definition_id, next_category
SQL);
$lastChange = [0, 0];
$batch = [];
$n = 0;
$domainContactID = \CRM_Core_BAO_Domain::getDomain()->contact_id;
$writeBatch = function() use (&$lastChange, &$batch, $domainContactID) {
if (empty($batch)) {
return;
}
$oldCategoryId = (int) ($lastChange[0] ?? 0);
$newCategoryId = (int) ($lastChange[1] ?? 0);
$oldName = $this->categories[$oldCategoryId]['label'] ?? E::ts('(Unknown)');
$subject = "$oldName → " . $this->categories[$newCategoryId]['label'];
// Create activity on the first contact
$a = Activity::create(FALSE)
->addValue('target_contact_id', $batch[0])
->addValue('activity_type_id:name', 'changed_contact_category')
->addValue('source_contact_id', $domainContactID)
->addValue('status_id:name', 'Completed')
->addValue('subject', $subject)
->addValue('Category_changes.previous_category_id', $oldCategoryId)
->addValue('Category_changes.new_category_id', $newCategoryId)
->execute()->first();
$activityID = (int) $a['id'];
// Join activity to all relevant contacts
CRM_Core_DAO::executeQuery("INSERT INTO civicrm_activity_contact (contact_id, activity_id, record_type_id)
SELECT id contact_id, $activityID activity_id, 3 record_type_id
FROM civicrm_contact_category cc
WHERE cc.category_definition_id = $oldCategoryId
AND cc.next_category = $newCategoryId
AND cc.id <> $batch[0]
")->free();
Civi::log()->debug(count($batch) . " changes: $subject");
};
while ($changes->fetch()) {
$n++;
if ($lastChange[0] !== $changes->category_definition_id || $lastChange[1] !== $changes->next_category) {
$writeBatch();
$lastChange = [$changes->category_definition_id, $changes->next_category];
$batch = [];
}
$batch[] = $changes->id;
}
$changes->free();
$writeBatch();
Civi::log()->debug('Apply changes', ['=change' => 'applyChanges', '=timed' => 1]);
CRM_Core_DAO::executeQuery("UPDATE civicrm_contact_category
SET category_definition_id = next_category
WHERE (category_definition_id IS NULL OR category_definition_id <> next_category) $singleContactClause")->free();
Civi::log()->debug('', ['=pop' => 1]);
$singleContactClause = $this->contact_id ? "WHERE id = $this->contact_id" : "";
$summary = CRM_Core_DAO::executeQuery("SELECT next_category, count(*) from civicrm_contact_category $singleContactClause GROUP BY next_category")->fetchAll();
$summary['changes'] = $n;
$_ = memory_get_peak_usage(TRUE);
$summary['memory_use'] = @round($_ / pow(1024, ($i = floor(log($_, 1024)))), 2) . ' ' . ['b', 'kb', 'mb', 'gb', 'tb', 'pb'][$i];
return $summary;
}
protected function refreshSmartGroups() {
$smartGroups = [];
foreach ($this->groupDetails as $groupID => $group) {
if (!empty($group['saved_search_id'])) {
$smartGroups[] = $groupID;
}
}
if ($smartGroups) {
Civi::log()->debug('Refreshing smart groups', ['=' => 'timed,start', 'groups' => $smartGroups]);
\CRM_Contact_BAO_GroupContactCache::loadAll($smartGroups);
Civi::log()->debug('', ['=' => 'pop']);
}
}
/**
* Ensure we have one category row per not-trashed contact and zero the next_category field.
*/
protected function resetTable() {
Civi::log()->debug('Resetting table', ['=' => 'start,timed']);
$singleContactClause = $this->contact_id ? "WHERE cat.id = $this->contact_id" : "";
// Delete our data for deleted contacts.
$dao = CRM_Core_DAO::executeQuery(<<<SQL
DELETE cat
FROM civicrm_contact_category cat
INNER JOIN civicrm_contact ct ON ct.id = cat.id AND ct.is_deleted = 1
$singleContactClause
SQL);
if ($dao->N) {
Civi::log()->debug("Deleted category data for {$dao->N} trashed contacts");
}
$dao->free();
// Zero our internal next_category field.
CRM_Core_DAO::executeQuery("UPDATE civicrm_contact_category cat SET next_category = 0 $singleContactClause;")->free();
Civi::log()->debug('stage 2');
// ensure we have all our contacts covered.
// Q: is it quicker to do a WHERE NOT EXISTS? A: nope.
$singleContactClause = $this->contact_id ? "AND id = $this->contact_id" : "";
CRM_Core_DAO::executeQuery(<<<SQL
INSERT INTO civicrm_contact_category
SELECT id, NULL AS category_definition_id, 0 AS next_category
FROM civicrm_contact
WHERE is_deleted = 0 $singleContactClause
AND NOT EXISTS (SELECT id FROM civicrm_contact_category WHERE id = civicrm_contact.id)
SQL)->free();
Civi::log()->debug('Done resetting table', ['=' => 'pop']);
}
/**
*
*/
protected function assignCategoryFromGroup(array $cat) {
$groupID = (int) $cat['search_data']['group_id'];
$group = $this->groupDetails[$groupID];
// Put unclaimed contacts in this group into this category.
$isSmart = !empty($group['saved_search_id']);
Civi::log()->debug($group['title'] . ($isSmart ? '(smart)' : ''), ['=' => 'timed', '=start' => "group$groupID"]);
$table = $isSmart ? 'civicrm_group_contact_cache' : 'civicrm_group_contact';
$statusWhere = $isSmart ? '' : 'AND gc.status = "Added"';
$andContactIdCriterion = $this->contact_id ? "AND cc.id=$this->contact_id" : '';
$sql = <<<SQL
UPDATE civicrm_contact_category cc
INNER JOIN $table gc ON gc.contact_id = cc.id AND group_id = $groupID $statusWhere
SET next_category = {$cat['id']}
WHERE next_category = 0 $andContactIdCriterion
SQL;
CRM_Core_DAO::executeQuery($sql)->free();
Civi::log()->debug('', ['=' => 'pop']);
}
/**
*
*/
protected function assignCategoryFromSearch(array $cat) {
$search = $this->searchDetails[$cat['search_data']['saved_search_id']];
Civi::log()->debug("Doing $cat[id] $cat[label]", ['=' => 'timed', '=start' => "ss" . $cat['search_data']['saved_search_id']]);
$apiParams = $search['api_params'];
if ($search['api_entity'] === 'Contact' && in_array('id', $apiParams['select'] ?? [])) {
$contactIdKey = 'id';
// We only need the ID.
$apiParams['select'] = ['id'];
if ($this->contact_id) {
// Limit to one contact.
$apiParams['where'][] = ['id', '=', $this->contact_id];
}
}
else {
throw new CRM_Core_Exception("can't figure out contactID in search: " . json_encode($search));
}
// We don't need them ordered.
unset($apiParams['orderBy']);
$contactIDs = civicrm_api4($search['api_entity'], 'get', $apiParams)->column($contactIdKey);
// Unsure if this batching is needed.
// It would be better if we could access the select SQL directly and do it all in an SQL update.
$batchSize = 10000;
while ($batch = array_splice($contactIDs, 0, $batchSize)) {
$batch = implode(',', $batch);
if (!preg_match('/^[0-9,]+$/', $batch)) {
// Surely this can never happen.
throw new CRM_Core_Exception("Erm, received non-integer contact IDs from Search $search[id]!");
}
$sql = <<<SQL
UPDATE civicrm_contact_category cc
SET next_category = {$cat['id']}
WHERE next_category = 0 AND id IN ($batch)
SQL;
CRM_Core_DAO::executeQuery($sql)->free();
}
Civi::log()->debug('Done', ['=' => 'pop']);
}
protected function assignDefaultCategory(array $cat) {
$id = (int) $cat['id'];
CRM_Core_DAO::executeQuery("UPDATE civicrm_contact_category SET next_category = $id WHERE next_category = 0;")->free();
}
}