3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
15 * @copyright CiviCRM LLC https://civicrm.org/licensing
18 use Civi\ActionSchedule\Event\MappingRegisterEvent
;
21 * This class contains functions for managing Scheduled Reminders
23 class CRM_Core_BAO_ActionSchedule
extends CRM_Core_DAO_ActionSchedule
{
26 * @param array $filters
27 * Filter by property (e.g. 'id').
30 * Array(scalar $id => Mapping $mapping).
32 * @throws \CRM_Core_Exception
34 public static function getMappings($filters = NULL) {
35 static $_action_mapping;
37 if ($_action_mapping === NULL) {
38 $event = \Civi
::dispatcher()
39 ->dispatch('civi.actionSchedule.getMappings',
40 new MappingRegisterEvent());
41 $_action_mapping = $event->getMappings();
44 if (empty($filters)) {
45 return $_action_mapping;
47 if (isset($filters['id'])) {
48 return [$filters['id'] => $_action_mapping[$filters['id']]];
50 throw new CRM_Core_Exception("getMappings() called with unsupported filter: " . implode(', ', array_keys($filters)));
54 * @param string|int $id
56 * @return \Civi\ActionSchedule\Mapping|NULL
57 * @throws \CRM_Core_Exception
59 public static function getMapping($id) {
60 $mappings = self
::getMappings();
61 return $mappings[$id] ??
NULL;
65 * For each entity, get a list of entity-value labels.
68 * Ex: $entityValueLabels[$mappingId][$valueId] = $valueLabel.
69 * @throws CRM_Core_Exception
71 public static function getAllEntityValueLabels() {
72 $entityValueLabels = [];
73 foreach (CRM_Core_BAO_ActionSchedule
::getMappings() as $mapping) {
74 /** @var \Civi\ActionSchedule\Mapping $mapping */
75 $entityValueLabels[$mapping->getId()] = $mapping->getValueLabels();
76 $valueLabel = ['- ' . strtolower($mapping->getValueHeader()) . ' -'];
77 $entityValueLabels[$mapping->getId()] = $valueLabel +
$entityValueLabels[$mapping->getId()];
79 return $entityValueLabels;
83 * For each entity, get a list of entity-status labels.
86 * Ex: $entityValueLabels[$mappingId][$valueId][$statusId] = $statusLabel.
88 public static function getAllEntityStatusLabels() {
89 $entityValueLabels = self
::getAllEntityValueLabels();
90 $entityStatusLabels = [];
91 foreach (CRM_Core_BAO_ActionSchedule
::getMappings() as $mapping) {
92 /** @var \Civi\ActionSchedule\Mapping $mapping */
93 $statusLabel = ['- ' . strtolower($mapping->getStatusHeader()) . ' -'];
94 $entityStatusLabels[$mapping->getId()] = $entityValueLabels[$mapping->getId()];
95 foreach ($entityStatusLabels[$mapping->getId()] as $kkey => & $vval) {
96 $vval = $statusLabel +
$mapping->getStatusLabels($kkey);
99 return $entityStatusLabels;
103 * Retrieve list of Scheduled Reminders.
105 * @param bool $namesOnly
106 * Return simple list of names.
108 * @param \Civi\ActionSchedule\Mapping|null $filterMapping
109 * Filter by the schedule's mapping type.
110 * @param int $filterValue
111 * Filter by the schedule's entity_value.
114 * (reference) reminder list
115 * @throws \CRM_Core_Exception
117 public static function &getList($namesOnly = FALSE, $filterMapping = NULL, $filterValue = NULL) {
123 cas.entity_value as entityValueIds,
124 cas.entity_status as entityStatusIds,
125 cas.start_action_date as entityDate,
126 cas.start_action_offset,
127 cas.start_action_unit,
128 cas.start_action_condition,
133 FROM civicrm_action_schedule cas
136 $where = " WHERE 1 ";
137 if ($filterMapping and $filterValue) {
138 $where .= " AND cas.entity_value = %1 AND cas.mapping_id = %2";
139 $queryParams[1] = [$filterValue, 'Integer'];
140 $queryParams[2] = [$filterMapping->getId(), 'String'];
142 $where .= " AND cas.used_for IS NULL";
144 $dao = CRM_Core_DAO
::executeQuery($query, $queryParams);
145 while ($dao->fetch()) {
146 /** @var Civi\ActionSchedule\Mapping $filterMapping */
147 $filterMapping = CRM_Utils_Array
::first(self
::getMappings([
148 'id' => $dao->mapping_id
,
150 $list[$dao->id
]['id'] = $dao->id
;
151 $list[$dao->id
]['title'] = $dao->title
;
152 $list[$dao->id
]['start_action_offset'] = $dao->start_action_offset
;
153 $list[$dao->id
]['start_action_unit'] = $dao->start_action_unit
;
154 $list[$dao->id
]['start_action_condition'] = $dao->start_action_condition
;
155 $list[$dao->id
]['entityDate'] = ucwords(str_replace('_', ' ', $dao->entityDate
));
156 $list[$dao->id
]['absolute_date'] = $dao->absolute_date
;
157 $list[$dao->id
]['entity'] = $filterMapping->getLabel();
158 $list[$dao->id
]['value'] = implode(', ', CRM_Utils_Array
::subset(
159 $filterMapping->getValueLabels(),
160 explode(CRM_Core_DAO
::VALUE_SEPARATOR
, $dao->entityValueIds
)
162 $list[$dao->id
]['status'] = implode(', ', CRM_Utils_Array
::subset(
163 $filterMapping->getStatusLabels($dao->entityValueIds
),
164 explode(CRM_Core_DAO
::VALUE_SEPARATOR
, $dao->entityStatusIds
)
166 $list[$dao->id
]['is_repeat'] = $dao->is_repeat
;
167 $list[$dao->id
]['is_active'] = $dao->is_active
;
174 * Add the scheduled reminders in the db.
176 * @param array $params
177 * An assoc array of name/value pairs.
179 * @return CRM_Core_DAO_ActionSchedule
180 * @throws \CRM_Core_Exception
182 public static function add(array $params): CRM_Core_DAO_ActionSchedule
{
183 return self
::writeRecord($params);
187 * Retrieve DB object based on input parameters.
189 * It also stores all the retrieved values in the default array.
191 * @param array $params
192 * (reference ) an assoc array of name/value pairs.
193 * @param array $values
194 * (reference ) an assoc array to hold the flattened values.
196 * @return CRM_Core_DAO_ActionSchedule|null
197 * object on success, null otherwise
199 public static function retrieve(&$params, &$values) {
200 if (empty($params)) {
203 $actionSchedule = new CRM_Core_DAO_ActionSchedule();
205 $actionSchedule->copyValues($params);
207 if ($actionSchedule->find(TRUE)) {
208 CRM_Core_DAO
::storeValues($actionSchedule, $values);
209 return $actionSchedule;
218 * ID of the Reminder to be deleted.
220 * @throws CRM_Core_Exception
222 public static function del($id) {
224 $dao = new CRM_Core_DAO_ActionSchedule();
226 if ($dao->find(TRUE)) {
231 throw new CRM_Core_Exception(ts('Invalid value passed to delete function.'));
235 * Update the is_active flag in the db.
238 * Id of the database record.
239 * @param bool $is_active
240 * Value we want to set the is_active field.
243 * true if we found and updated the object, else false
245 public static function setIsActive($id, $is_active) {
246 return CRM_Core_DAO
::setFieldValue('CRM_Core_DAO_ActionSchedule', $id, 'is_active', $is_active);
250 * @param int $mappingID
253 * @throws CRM_Core_Exception
255 public static function sendMailings($mappingID, $now) {
256 $mapping = CRM_Utils_Array
::first(self
::getMappings([
260 $actionSchedule = new CRM_Core_DAO_ActionSchedule();
261 $actionSchedule->mapping_id
= $mappingID;
262 $actionSchedule->is_active
= 1;
263 $actionSchedule->find(FALSE);
265 while ($actionSchedule->fetch()) {
266 $query = CRM_Core_BAO_ActionSchedule
::prepareMailingQuery($mapping, $actionSchedule);
267 $dao = CRM_Core_DAO
::executeQuery($query,
268 [1 => [$actionSchedule->id
, 'Integer']]
271 $multilingual = CRM_Core_I18n
::isMultilingual();
272 while ($dao->fetch()) {
273 // switch language if necessary
275 $preferred_language = CRM_Core_DAO
::getFieldValue('CRM_Contact_DAO_Contact', $dao->contactID
, 'preferred_language');
276 CRM_Core_BAO_ActionSchedule
::setCommunicationLanguage($actionSchedule->communication_language
, $preferred_language);
281 $tokenProcessor = self
::createTokenProcessor($actionSchedule, $mapping);
282 $tokenProcessor->addRow()
283 ->context('contactId', $dao->contactID
)
284 ->context('actionSearchResult', (object) $dao->toArray());
285 foreach ($tokenProcessor->evaluate()->getRows() as $tokenRow) {
286 if ($actionSchedule->mode
=== 'SMS' ||
$actionSchedule->mode
=== 'User_Preference') {
287 CRM_Utils_Array
::extend($errors, self
::sendReminderSms($tokenRow, $actionSchedule, $dao->contactID
));
290 if ($actionSchedule->mode
=== 'Email' ||
$actionSchedule->mode
=== 'User_Preference') {
291 CRM_Utils_Array
::extend($errors, self
::sendReminderEmail($tokenRow, $actionSchedule, $dao->contactID
));
293 // insert activity log record if needed
294 if ($actionSchedule->record_activity
&& empty($errors)) {
295 $caseID = empty($dao->case_id
) ?
NULL : $dao->case_id
;
296 CRM_Core_BAO_ActionSchedule
::createMailingActivity($tokenRow, $mapping, $dao->contactID
, $dao->entityID
, $caseID);
300 catch (\Civi\Token\TokenException
$e) {
301 $errors['token_exception'] = $e->getMessage();
304 // update action log record
306 'id' => $dao->reminderID
,
307 'is_error' => !empty($errors),
308 'message' => empty($errors) ?
"null" : implode(' ', $errors),
309 'action_date_time' => $now,
311 CRM_Core_BAO_ActionLog
::create($logParams);
318 * Build a list of the contacts to send to.
320 * @param string $mappingID
321 * Value from the mapping_id field in the civicrm_action_schedule able. It might be a string like
322 * 'contribpage' for an older class like CRM_Contribute_ActionMapping_ByPage of for ones following
323 * more recent patterns, an integer.
325 * @param array $params
327 * @throws API_Exception
328 * @throws \CRM_Core_Exception
330 public static function buildRecipientContacts(string $mappingID, $now, $params = []) {
331 $actionSchedule = new CRM_Core_DAO_ActionSchedule();
333 $actionSchedule->mapping_id
= $mappingID;
334 $actionSchedule->is_active
= 1;
335 if (!empty($params)) {
336 _civicrm_api3_dao_set_filter($actionSchedule, $params, FALSE);
338 $actionSchedule->find();
340 while ($actionSchedule->fetch()) {
341 /** @var \Civi\ActionSchedule\Mapping $mapping */
342 $mapping = CRM_Utils_Array
::first(self
::getMappings([
345 $builder = new \Civi\ActionSchedule\
RecipientBuilder($now, $actionSchedule, $mapping);
351 * Main processing callback for sending out scheduled reminders.
354 * @param array $params
356 * @throws \API_Exception
357 * @throws \CRM_Core_Exception
359 public static function processQueue($now = NULL, $params = []): void
{
360 $now = $now ? CRM_Utils_Time
::setTime($now) : CRM_Utils_Time
::getTime();
362 $mappings = CRM_Core_BAO_ActionSchedule
::getMappings();
363 foreach ($mappings as $mappingID => $mapping) {
364 CRM_Core_BAO_ActionSchedule
::buildRecipientContacts((string) $mappingID, $now, $params);
365 CRM_Core_BAO_ActionSchedule
::sendMailings($mappingID, $now);
371 * @param int $mappingID
373 * @return null|string
375 public static function isConfigured($id, $mappingID) {
376 $queryString = "SELECT count(id) FROM civicrm_action_schedule
377 WHERE mapping_id = %1 AND
381 1 => [$mappingID, 'String'],
382 2 => [$id, 'Integer'],
384 return CRM_Core_DAO
::singleValueQuery($queryString, $params);
388 * @param int $mappingID
389 * @param $recipientType
393 public static function getRecipientListing($mappingID, $recipientType) {
398 /** @var \Civi\ActionSchedule\Mapping $mapping */
399 $mapping = CRM_Utils_Array
::first(CRM_Core_BAO_ActionSchedule
::getMappings([
402 return $mapping->getRecipientListing($recipientType);
406 * @param $communication_language
407 * @param $preferred_language
409 public static function setCommunicationLanguage($communication_language, $preferred_language) {
410 $currentLocale = CRM_Core_I18n
::getLocale();
411 $language = $currentLocale;
413 // prepare the language for the email
414 if ($communication_language == CRM_Core_I18n
::AUTO
) {
415 if (!empty($preferred_language)) {
416 $language = $preferred_language;
420 $language = $communication_language;
423 // language not in the existing language, use default
424 $languages = CRM_Core_I18n
::languages(TRUE);
425 if (!array_key_exists($language, $languages)) {
426 $language = $currentLocale;
429 // change the language
430 $i18n = CRM_Core_I18n
::singleton();
431 $i18n->setLocale($language);
435 * Save a record about the delivery of a reminder email.
437 * WISHLIST: Instead of saving $actionSchedule->body_html, call this immediately after
438 * sending the message and pass in the fully rendered text of the message.
440 * @param object $tokenRow
441 * @param Civi\ActionSchedule\Mapping $mapping
442 * @param int $contactID
443 * @param int $entityID
444 * @param int|null $caseID
445 * @throws CRM_Core_Exception
447 protected static function createMailingActivity($tokenRow, $mapping, $contactID, $entityID, $caseID) {
448 $session = CRM_Core_Session
::singleton();
450 if ($mapping->getEntity() == 'civicrm_membership') {
451 // @todo - not required with api
453 = CRM_Core_PseudoConstant
::getKey('CRM_Activity_BAO_Activity', 'activity_type_id', 'Membership Renewal Reminder');
456 // @todo - not required with api
458 = CRM_Core_PseudoConstant
::getKey('CRM_Activity_BAO_Activity', 'activity_type_id', 'Reminder Sent');
462 'subject' => $tokenRow->render('subject'),
463 'details' => $tokenRow->render('body_html'),
464 'source_contact_id' => $session->get('userID') ?
$session->get('userID') : $contactID,
465 'target_contact_id' => $contactID,
466 // @todo - not required with api
467 'activity_date_time' => CRM_Utils_Time
::getTime('YmdHis'),
468 // @todo - not required with api
469 'status_id' => CRM_Core_PseudoConstant
::getKey('CRM_Activity_BAO_Activity', 'status_id', 'Completed'),
470 'activity_type_id' => $activityTypeID,
471 'source_record_id' => $entityID,
473 // @todo use api, remove all the above wrangling
474 $activity = CRM_Activity_BAO_Activity
::create($activityParams);
476 //file reminder on case if source activity is a case activity
477 if (!empty($caseID)) {
478 $caseActivityParams = [];
479 $caseActivityParams['case_id'] = $caseID;
480 $caseActivityParams['activity_id'] = $activity->id
;
481 CRM_Case_BAO_Case
::processCaseActivity($caseActivityParams);
486 * @param \Civi\ActionSchedule\MappingInterface $mapping
487 * @param \CRM_Core_DAO_ActionSchedule $actionSchedule
491 protected static function prepareMailingQuery($mapping, $actionSchedule) {
492 $select = CRM_Utils_SQL_Select
::from('civicrm_action_log reminder')
493 ->select("reminder.id as reminderID, reminder.contact_id as contactID, reminder.entity_table as entityTable, reminder.*, e.id AS entityID")
494 ->join('e', "!casMailingJoinType !casMappingEntity e ON !casEntityJoinExpr")
495 ->select("e.id as entityID, e.*")
496 ->where("reminder.action_schedule_id = #casActionScheduleId")
497 ->where("reminder.action_date_time IS NULL")
499 'casActionScheduleId' => $actionSchedule->id
,
500 'casMailingJoinType' => ($actionSchedule->limit_to
== 0) ?
'LEFT JOIN' : 'INNER JOIN',
501 'casMappingId' => $mapping->getId(),
502 'casMappingEntity' => $mapping->getEntity(),
503 'casEntityJoinExpr' => 'e.id = IF(reminder.entity_table = "civicrm_contact", reminder.contact_id, reminder.entity_id)',
506 if ($actionSchedule->limit_to
== 0) {
507 $select->where("e.id = reminder.entity_id OR reminder.entity_table = 'civicrm_contact'");
512 'civi.actionSchedule.prepareMailingQuery',
513 new \Civi\ActionSchedule\Event\
MailingQueryEvent($actionSchedule, $mapping, $select)
516 return $select->toSQL();
520 * @param \Civi\Token\TokenRow $tokenRow
521 * @param CRM_Core_DAO_ActionSchedule $schedule
522 * @param int $toContactID
523 * @throws CRM_Core_Exception
525 * List of error messages.
527 protected static function sendReminderSms($tokenRow, $schedule, $toContactID) {
528 $toPhoneNumber = self
::pickSmsPhoneNumber($toContactID);
529 if (!$toPhoneNumber) {
530 return ["sms_phone_missing" => "Couldn't find recipient's phone number."];
533 // dev/core#369 If an SMS provider is deleted then the relevant row in the action_schedule_table is set to NULL
534 // So we need to exclude them.
535 if (CRM_Utils_System
::isNull($schedule->sms_provider_id
)) {
536 return ["sms_provider_missing" => "SMS reminder cannot be sent because the SMS provider has been deleted."];
539 $messageSubject = $tokenRow->render('subject');
540 $sms_body_text = $tokenRow->render('sms_body_text');
542 $session = CRM_Core_Session
::singleton();
543 $userID = $session->get('userID') ?
$session->get('userID') : $tokenRow->context
['contactId'];
545 'To' => $toPhoneNumber,
546 'provider_id' => $schedule->sms_provider_id
,
547 'activity_subject' => $messageSubject,
549 $activityTypeID = CRM_Core_PseudoConstant
::getKey('CRM_Activity_BAO_Activity', 'activity_type_id', 'SMS');
551 'source_contact_id' => $userID,
552 'activity_type_id' => $activityTypeID,
553 'activity_date_time' => date('YmdHis'),
554 'subject' => $messageSubject,
555 'details' => $sms_body_text,
556 'status_id' => CRM_Core_PseudoConstant
::getKey('CRM_Activity_BAO_Activity', 'status_id', 'Completed'),
559 $activity = CRM_Activity_BAO_Activity
::create($activityParams);
562 CRM_Activity_BAO_Activity
::sendSMSMessage($tokenRow->context
['contactId'],
569 catch (CRM_Core_Exception
$e) {
570 return ["sms_send_error" => $e->getMessage()];
577 * @param CRM_Core_DAO_ActionSchedule $actionSchedule
580 * Ex: "Alice <alice@example.org>".
581 * @throws \CRM_Core_Exception
583 protected static function pickFromEmail($actionSchedule) {
584 $domainValues = CRM_Core_BAO_Domain
::getNameAndEmail();
585 $fromEmailAddress = "$domainValues[0] <$domainValues[1]>";
586 if ($actionSchedule->from_email
) {
587 $fromEmailAddress = "\"$actionSchedule->from_name\" <$actionSchedule->from_email>";
588 return $fromEmailAddress;
590 return $fromEmailAddress;
594 * @param \Civi\Token\TokenRow $tokenRow
595 * @param CRM_Core_DAO_ActionSchedule $schedule
596 * @param int $toContactID
598 * List of error messages.
600 protected static function sendReminderEmail($tokenRow, $schedule, $toContactID): array {
601 $toEmail = CRM_Contact_BAO_Contact
::getPrimaryEmail($toContactID, TRUE);
603 return ["email_missing" => "Couldn't find recipient's email address."];
606 $body_text = $tokenRow->render('body_text');
607 $body_html = $tokenRow->render('body_html');
608 if (!$schedule->body_text
) {
609 $body_text = CRM_Utils_String
::htmlToText($body_html);
612 // set up the parameters for CRM_Utils_Mail::send
614 'groupName' => 'Scheduled Reminder Sender',
615 'from' => self
::pickFromEmail($schedule),
616 'toName' => $tokenRow->context
['contact']['display_name'],
617 'toEmail' => $toEmail,
618 'subject' => $tokenRow->render('subject'),
619 'entity' => 'action_schedule',
620 'entity_id' => $schedule->id
,
623 if (!$body_html ||
$tokenRow->context
['contact']['preferred_mail_format'] === 'Text' ||
624 $tokenRow->context
['contact']['preferred_mail_format'] === 'Both'
626 // render the & entities in text mode, so that the links work
627 $mailParams['text'] = str_replace('&', '&', $body_text);
629 if ($body_html && ($tokenRow->context
['contact']['preferred_mail_format'] === 'HTML' ||
630 $tokenRow->context
['contact']['preferred_mail_format'] === 'Both'
633 $mailParams['html'] = $body_html;
635 $result = CRM_Utils_Mail
::send($mailParams);
637 return ['email_fail' => 'Failed to send message'];
644 * @param CRM_Core_DAO_ActionSchedule $schedule
645 * @param \Civi\ActionSchedule\Mapping $mapping
646 * @return \Civi\Token\TokenProcessor
648 protected static function createTokenProcessor($schedule, $mapping) {
649 $tp = new \Civi\Token\
TokenProcessor(\Civi
::dispatcher(), [
650 'controller' => __CLASS__
,
651 'actionSchedule' => $schedule,
652 'actionMapping' => $mapping,
655 $tp->addMessage('body_text', $schedule->body_text
, 'text/plain');
656 $tp->addMessage('body_html', $schedule->body_html
, 'text/html');
657 $tp->addMessage('sms_body_text', $schedule->sms_body_text
, 'text/plain');
658 $tp->addMessage('subject', $schedule->subject
, 'text/plain');
663 * Pick SMS phone number.
665 * @param int $smsToContactId
667 * @return NULL|string
669 protected static function pickSmsPhoneNumber($smsToContactId) {
670 $toPhoneNumbers = CRM_Core_BAO_Phone
::allPhones($smsToContactId, FALSE, 'Mobile', [
675 //to get primary mobile ph,if not get a first mobile phONE
676 if (!empty($toPhoneNumbers)) {
677 $toPhoneNumberDetails = reset($toPhoneNumbers);
678 $toPhoneNumber = $toPhoneNumberDetails['phone'] ??
NULL;
679 return $toPhoneNumber;
685 * Get the list of generic recipient types supported by all entities/mappings.
688 * array(mixed $value => string $label).
690 public static function getAdditionalRecipients(): array {
692 'manual' => ts('Choose Recipient(s)'),
693 'group' => ts('Select Group'),