diff --git a/include/class.task.php b/include/class.task.php index 5f35fd5420d92575dcfa2b6afcc8a8718e79632e..676cb9d5e04ac8d82f7e67d0a6bea7e8163bd7c4 100644 --- a/include/class.task.php +++ b/include/class.task.php @@ -63,6 +63,7 @@ class TaskModel extends VerySimpleModel { const PERM_EDIT = 'task.edit'; const PERM_ASSIGN = 'task.assign'; const PERM_TRANSFER = 'task.transfer'; + const PERM_REPLY = 'task.reply'; const PERM_CLOSE = 'task.close'; const PERM_DELETE = 'task.delete'; @@ -87,6 +88,11 @@ class TaskModel extends VerySimpleModel { /* @trans */ 'Transfer', 'desc' => /* @trans */ 'Ability to transfer tasks between departments'), + self::PERM_REPLY => array( + 'title' => + /* @trans */ 'Post Reply', + 'desc' => + /* @trans */ 'Ability to post task update'), self::PERM_CLOSE => array( 'title' => /* @trans */ 'Close', @@ -193,12 +199,36 @@ class TaskModel extends VerySimpleModel { RolePermission::register(/* @trans */ 'Tasks', TaskModel::getPermissions()); -class Task extends TaskModel implements Threadable { +class Task extends TaskModel implements RestrictedAccess, Threadable { var $form; var $entry; var $_thread; var $_entries; + var $_answers; + + var $lastrespondent; + + function __onload() { + $this->loadDynamicData(); + } + + function loadDynamicData() { + if (!isset($this->_answers)) { + $this->_answers = array(); + foreach (DynamicFormEntryAnswer::objects() + ->filter(array( + 'entry__object_id' => $this->getId(), + 'entry__object_type' => ObjectModel::OBJECT_TYPE_TASK + )) as $answer + ) { + $tag = mb_strtolower($answer->field->name) + ?: 'field.' . $answer->field->id; + $this->_answers[$tag] = $answer; + } + } + return $this->_answers; + } function getStatus() { return $this->isOpen() ? __('Open') : __('Completed'); @@ -281,6 +311,27 @@ class Task extends TaskModel implements Threadable { return $assignees ? implode($glue, $assignees):''; } + function getLastRespondent() { + + if (!isset($this->lastrespondent)) { + $this->lastrespondent = Staff::objects() + ->filter(array( + 'staff_id' => static::objects() + ->filter(array( + 'thread__entries__type' => 'R', + 'thread__entries__staff_id__gt' => 0 + )) + ->values_flat('thread__entries__staff_id') + ->order_by('-thread__entries__id') + ->limit(1) + )) + ->first() + ?: false; + } + + return $this->lastrespondent; + } + function getParticipants() { $participants = array(); foreach ($this->getThread()->collaborators as $c) @@ -402,7 +453,9 @@ class Task extends TaskModel implements Threadable { return false; } - $this->save(true); + if (!$this->save(true)) + return false; + if ($comments) { $errors = array(); $this->postNote(array( @@ -449,8 +502,15 @@ class Task extends TaskModel implements Threadable { } /* util routines */ + + function logEvent($state, $data=null, $user=null, $annul=null) { + $this->getThread()->getEvents()->log($this, $state, $data, $user, $annul); + } + function assign(AssignmentForm $form, &$errors, $alert=true) { + global $thisstaff; + $evd = array(); $assignee = $form->getAssignee(); if ($assignee instanceof Staff) { if ($this->getStaffId() == $assignee->getId()) { @@ -462,6 +522,11 @@ class Task extends TaskModel implements Threadable { $errors['assignee'] = __('Agent is unavailable for assignment'); } else { $this->staff_id = $assignee->getId(); + if ($thisstaff && $thisstaff->getId() == $assignee->getId()) + $evd['claim'] = true; + else + $evd['staff'] = $assignee; + } } elseif ($assignee instanceof Team) { if ($this->getTeamId() == $assignee->getId()) { @@ -471,7 +536,7 @@ class Task extends TaskModel implements Threadable { ); } else { $this->team_id = $assignee->getId(); - + $evd = array('team' => $assignee->getId()); } } else { $errors['assignee'] = __('Unknown assignee'); @@ -480,6 +545,8 @@ class Task extends TaskModel implements Threadable { if ($errors || !$this->save(true)) return false; + $this->logEvent('assigned', $evd); + $this->onAssignment($assignee, $form->getField('comments')->getClean(), $alert); @@ -487,39 +554,90 @@ class Task extends TaskModel implements Threadable { return true; } - function onAssignment($assignee, $note='', $alert=true) { - global $thisstaff; + function onAssignment($assignee, $comments='', $alert=true) { + global $thisstaff, $cfg; if (!is_object($assignee)) return false; $assigner = $thisstaff ?: __('SYSTEM (Auto Assignment)'); + //Assignment completed... post internal note. - $title = sprintf(__('Task assigned to %s'), - (string) $assignee); + $note = null; + if ($comments) { - if (!$note) { - $note = $title; - $title = ''; + $title = sprintf(__('Task assigned to %s'), + (string) $assignee); + + $errors = array(); + $note = $this->postNote( + array('note' => $comments, 'title' => $title), + $errors, + $assigner, + false); } - $errors = array(); - $note = $this->postNote( - array('note' => $note, 'title' => $title), - $errors, - $assigner, - false); - - // Send alerts out - if (!$alert) + // Send alerts out if enabled. + if (!$alert || !$cfg->alertONTaskAssignment()) return false; + if (!($dept=$this->getDept()) + || !($tpl = $dept->getTemplate()) + || !($email = $dept->getAlertEmail()) + ) { + return true; + } + + // Recipients + $recipients = array(); + if ($assignee instanceof Staff) { + if ($cfg->alertStaffONTaskAssignment()) + $recipients[] = $assignee; + } elseif (($assignee instanceof Team) && $assignee->alertsEnabled()) { + if ($cfg->alertTeamMembersONTaskAssignment() && ($members=$assignee->getMembers())) + $recipients = array_merge($recipients, $members); + elseif ($cfg->alertTeamLeadONTaskAssignment() && ($lead=$assignee->getTeamLead())) + $recipients[] = $lead; + } + + if ($recipients + && ($msg=$tpl->getTaskAssignmentAlertMsgTemplate())) { + + $msg = $this->replaceVars($msg->asArray(), + array('comments' => $comments, + 'assignee' => $assignee, + 'assigner' => $assigner + ) + ); + // Send the alerts. + $sentlist = array(); + $options = $note instanceof ThreadEntry + ? array( + 'inreplyto' => $note->getEmailMessageId(), + 'references' => $note->getEmailReferences(), + 'thread' => $note) + : array(); + + foreach ($recipients as $k => $staff) { + if (!is_object($staff) + || !$staff->isAvailable() + || in_array($staff->getEmail(), $sentlist)) { + continue; + } + + $alert = $this->replaceVars($msg, array('recipient' => $staff)); + $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options); + $sentlist[] = $staff->getEmail(); + } + } + return true; } function transfer(TransferForm $form, &$errors, $alert=true) { - global $thisstaff; + global $thisstaff, $cfg; + $cdept = $this->getDept(); $dept = $form->getDept(); if (!$dept || !($dept instanceof Dept)) $errors['dept'] = __('Department selection required'); @@ -531,25 +649,77 @@ class Task extends TaskModel implements Threadable { if ($errors || !$this->save()) return false; - // Transfer completed... post internal note. - $title = sprintf(__('%s transferred to %s department'), - __('Task'), - $dept->getName()); + // Log transfer event + $this->logEvent('transferred'); + // Post internal note if any $note = $form->getField('comments')->getClean(); - if (!$note) { - $note = $title; - $title = ''; + if ($note) { + $title = sprintf(__('%1$s transferred from %2$s to %3$s'), + __('Task'), + $cdept->getName(), + $dept->getName()); + + $_errors = array(); + $note = $this->postNote( + array('note' => $note, 'title' => $title), + $_errors, $thisstaff, false); } - $_errors = array(); - $note = $this->postNote( - array('note' => $note, 'title' => $title), - $_errors, $thisstaff, false); // Send alerts if requested && enabled. - if (!$alert) + if (!$alert || !$cfg->alertONTaskTransfer()) return true; + if (($email = $dept->getAlertEmail()) + && ($tpl = $dept->getTemplate()) + && ($msg=$tpl->getTaskTransferAlertMsgTemplate())) { + + $msg = $this->replaceVars($msg->asArray(), + array('comments' => $note, 'staff' => $thisstaff)); + // Recipients + $recipients = array(); + // Assigned staff or team... if any + if ($this->isAssigned() && $cfg->alertAssignedONTaskTransfer()) { + if($this->getStaffId()) + $recipients[] = $this->getStaff(); + elseif ($this->getTeamId() + && ($team=$this->getTeam()) + && ($members=$team->getMembers()) + ) { + $recipients = array_merge($recipients, $members); + } + } elseif ($cfg->alertDeptMembersONTaskTransfer() && !$this->isAssigned()) { + // Only alerts dept members if the task is NOT assigned. + if ($members = $dept->getMembersForAlerts()) + $recipients = array_merge($recipients, $members); + } + + // Always alert dept manager?? + if ($cfg->alertDeptManagerONTaskTransfer() + && ($manager=$dept->getManager())) { + $recipients[] = $manager; + } + + $sentlist = $options = array(); + if ($note instanceof ThreadEntry) { + $options += array( + 'inreplyto'=>$note->getEmailMessageId(), + 'references'=>$note->getEmailReferences(), + 'thread'=>$note); + } + + foreach ($recipients as $k=>$staff) { + if (!is_object($staff) + || !$staff->isAvailable() + || in_array($staff->getEmail(), $sentlist) + ) { + continue; + } + $alert = $this->replaceVars($msg, array('recipient' => $staff)); + $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options); + $sentlist[] = $staff->getEmail(); + } + } return true; } @@ -569,12 +739,77 @@ class Task extends TaskModel implements Threadable { if (!($note=$this->getThread()->addNote($vars, $errors))) return null; + $assignee = $this->getStaff(); + if (isset($vars['task_status'])) $this->setStatus($vars['task_status']); + $this->onActivity(array( + 'activity' => $note->getActivity(), + 'threadentry' => $note, + 'assignee' => $assignee + ), $alert); + return $note; } + /* public */ + function postReply($vars, &$errors, $alert = true) { + global $thisstaff, $cfg; + + + if (!$vars['poster'] && $thisstaff) + $vars['poster'] = $thisstaff; + + if (!$vars['staffId'] && $thisstaff) + $vars['staffId'] = $thisstaff->getId(); + + if (!$vars['ip_address'] && $_SERVER['REMOTE_ADDR']) + $vars['ip_address'] = $_SERVER['REMOTE_ADDR']; + + if (!($response = $this->getThread()->addResponse($vars, $errors))) + return null; + + $assignee = $this->getStaff(); + // Set status - if checked. + if ($vars['reply_status_id'] + && $vars['reply_status_id'] != $this->getStatusId() + ) { + $this->setStatus($vars['reply_status_id']); + } + + /* + // TODO: add auto claim setting for tasks. + // Claim on response bypasses the department assignment restrictions + if ($thisstaff + && $this->isOpen() + && !$this->getStaffId() + && $cfg->autoClaimTasks) + ) { + $this->staff_id = $thisstaff->getId(); + } + */ + + $this->lastrespondent = $response->staff; + $this->save(); + + // Send activity alert to agents + $activity = $vars['activity'] ?: $response->getActivity(); + $this->onActivity( array( + 'activity' => $activity, + 'threadentry' => $response, + 'assignee' => $assignee, + )); + // Send alert to collaborators + if ($alert && $vars['emailcollab']) { + $this->notifyCollaborators($response, + array('signature' => $signature) + ); + } + + return $response; + } + function pdfExport($options=array()) { global $thisstaff; @@ -596,6 +831,233 @@ class Task extends TaskModel implements Threadable { exit; } + /* util routines */ + function replaceVars($input, $vars = array()) { + global $ost; + + return $ost->replaceTemplateVariables($input, + array_merge($vars, array('task' => $this))); + } + + function asVar() { + return $this->getNumber(); + } + + function getVar($tag) { + global $cfg; + + if ($tag && is_callable(array($this, 'get'.ucfirst($tag)))) + return call_user_func(array($this, 'get'.ucfirst($tag))); + + switch(mb_strtolower($tag)) { + case 'phone': + case 'phone_number': + return $this->getPhoneNumber(); + break; + case 'staff_link': + return sprintf('%s/scp/tasks.php?id=%d', $cfg->getBaseUrl(), $this->getId()); + break; + case 'create_date': + return new FormattedDate($this->getCreateDate()); + break; + case 'due_date': + if ($due = $this->getEstDueDate()) + return new FormattedDate($due); + break; + case 'close_date': + if ($this->isClosed()) + return new FormattedDate($this->getCloseDate()); + break; + case 'last_update': + return new FormattedDate($this->last_update); + default: + if (isset($this->_answers[$tag])) + // The answer object is retrieved here which will + // automatically invoke the toString() method when the + // answer is coerced into text + return $this->_answers[$tag]; + } + return false; + } + + static function getVarScope() { + $base = array( + 'assigned' => __('Assigned agent and/or team'), + 'close_date' => array( + 'class' => 'FormattedDate', 'desc' => __('Date Closed'), + ), + 'create_date' => array( + 'class' => 'FormattedDate', 'desc' => __('Date created'), + ), + 'dept' => array( + 'class' => 'Dept', 'desc' => __('Department'), + ), + 'due_date' => array( + 'class' => 'FormattedDate', 'desc' => __('Due Date'), + ), + 'number' => __('Task number'), + 'recipients' => array( + 'class' => 'UserList', 'desc' => __('List of all recipient names'), + ), + 'status' => __('Status'), + 'staff' => array( + 'class' => 'Staff', 'desc' => __('Assigned/closing agent'), + ), + 'subject' => 'Subject', + 'team' => array( + 'class' => 'Team', 'desc' => __('Assigned/closing team'), + ), + 'thread' => array( + 'class' => 'TaskThread', 'desc' => __('Task Thread'), + ), + 'last_update' => array( + 'class' => 'FormattedDate', 'desc' => __('Time of last update'), + ), + ); + + $extra = VariableReplacer::compileFormScope(TaskForm::getInstance()); + return $base + $extra; + } + + function onActivity($vars, $alert=true) { + global $cfg, $thisstaff; + + if (!$alert // Check if alert is enabled + || !$cfg->alertONTaskActivity() + || !($dept=$this->getDept()) + || !($email=$cfg->getAlertEmail()) + || !($tpl = $dept->getTemplate()) + || !($msg=$tpl->getTaskActivityAlertMsgTemplate()) + ) { + return; + } + + // Alert recipients + $recipients = array(); + //Last respondent. + if ($cfg->alertLastRespondentONTaskActivity()) + $recipients[] = $this->getLastRespondent(); + + // Assigned staff / team + if ($cfg->alertAssignedONTaskActivity()) { + if (isset($vars['assignee']) + && $vars['assignee'] instanceof Staff) + $recipients[] = $vars['assignee']; + elseif ($this->isOpen() && ($assignee = $this->getStaff())) + $recipients[] = $assignee; + + if ($team = $this->getTeam()) + $recipients = array_merge($recipients, $team->getMembers()); + } + + // Dept manager + if ($cfg->alertDeptManagerONTaskActivity() && $dept && $dept->getManagerId()) + $recipients[] = $dept->getManager(); + + $options = array(); + $staffId = $thisstaff ? $thisstaff->getId() : 0; + if ($vars['threadentry'] && $vars['threadentry'] instanceof ThreadEntry) { + $options = array( + 'inreplyto' => $vars['threadentry']->getEmailMessageId(), + 'references' => $vars['threadentry']->getEmailReferences(), + 'thread' => $vars['threadentry']); + + // Activity details + if (!$vars['message']) + $vars['message'] = $vars['threadentry']; + + // Staff doing the activity + $staffId = $vars['threadentry']->getStaffId() ?: $staffId; + } + + $msg = $this->replaceVars($msg->asArray(), + array( + 'note' => $vars['threadentry'], // For compatibility + 'activity' => $vars['activity'], + 'message' => $vars['message'])); + + $isClosed = $this->isClosed(); + $sentlist=array(); + foreach ($recipients as $k=>$staff) { + if (!is_object($staff) + // Don't bother vacationing staff. + || !$staff->isAvailable() + // No need to alert the poster! + || $staffId == $staff->getId() + // No duplicates. + || isset($sentlist[$staff->getEmail()]) + // Make sure staff has access to task + || ($isClosed && !$this->checkStaffPerm($staff)) + ) { + continue; + } + $alert = $this->replaceVars($msg, array('recipient' => $staff)); + $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options); + $sentlist[$staff->getEmail()] = 1; + } + + } + + /* + * Notify collaborators on response or new message + * + */ + function notifyCollaborators($entry, $vars = array()) { + global $cfg; + + if (!$entry instanceof ThreadEntry + || !($recipients=$this->getThread()->getParticipants()) + || !($dept=$this->getDept()) + || !($tpl=$dept->getTemplate()) + || !($msg=$tpl->getTaskActivityNoticeMsgTemplate()) + || !($email=$dept->getEmail()) + ) { + return; + } + + // Who posted the entry? + $skip = array(); + if ($entry instanceof Message) { + $poster = $entry->getUser(); + // Skip the person who sent in the message + $skip[$entry->getUserId()] = 1; + // Skip all the other recipients of the message + foreach ($entry->getAllEmailRecipients() as $R) { + foreach ($recipients as $R2) { + if (0 === strcasecmp($R2->getEmail(), $R->mailbox.'@'.$R->host)) { + $skip[$R2->getUserId()] = true; + break; + } + } + } + } else { + $poster = $entry->getStaff(); + } + + $vars = array_merge($vars, array( + 'message' => (string) $entry, + 'poster' => $poster ?: _S('A collaborator'), + ) + ); + + $msg = $this->replaceVars($msg->asArray(), $vars); + + $attachments = $cfg->emailAttachments()?$entry->getAttachments():array(); + $options = array('inreplyto' => $entry->getEmailMessageId(), + 'thread' => $entry); + + foreach ($recipients as $recipient) { + // Skip folks who have already been included on this part of + // the conversation + if (isset($skip[$recipient->getUserId()])) + continue; + $notice = $this->replaceVars($msg, array('recipient' => $recipient)); + $email->send($recipient, $notice['subj'], $notice['body'], $attachments, + $options); + } + } + + /* static routines */ static function lookupIdByNumber($number) { $sql = 'SELECT id FROM '.TASK_TABLE .' WHERE `number`='.db_input($number); @@ -710,7 +1172,7 @@ class Task extends TaskModel implements Threadable { .sprintf('task.flags & %d != 0 ', TaskModel::ISOPEN) .')'; - if(!$staff->showAssignedOnly() && ($depts=$staff->getDepts())) //Staff with limited access just see Assigned tickets. + if(!$staff->showAssignedOnly() && ($depts=$staff->getDepts())) //Staff with limited access just see Assigned tasks. $where[] = 'task.dept_id IN('.implode(',', db_input($depts)).') '; $where = implode(' OR ', $where); diff --git a/include/class.thread.php b/include/class.thread.php index 20cb0f8f246a26e351813595d7c778a640422c70..eb1cd24ada471e2712634009370c0a24d91cf741 100644 --- a/include/class.thread.php +++ b/include/class.thread.php @@ -54,6 +54,7 @@ class Thread extends VerySimpleModel { var $_object; var $_collaborators; // Cache for collabs + var $_participants; function getId() { return $this->id; @@ -211,13 +212,33 @@ class Thread extends VerySimpleModel { return true; } + + + //UserList of participants (collaborators) + function getParticipants() { + + if (!isset($this->_participants)) { + $list = new UserList(); + if ($collabs = $this->getActiveCollaborators()) { + foreach ($collabs as $c) + $list->add($c); + } + + $this->_participants = $list; + } + + return $this->_participants; + } + + // Render thread function render($type=false, $options=array()) { $mode = $options['mode'] ?: self::MODE_STAFF; // Register thread actions prior to rendering the thread. - include_once INCLUDE_DIR . 'class.thread_actions.php'; + if (!class_exists('tea_showemailheaders')) + include_once INCLUDE_DIR . 'class.thread_actions.php'; $entries = $this->getEntries(); if ($type && is_array($type)) @@ -1037,6 +1058,10 @@ implements TemplateVariable { return $this->email_info->save(); } + function getActivity() { + return new ThreadActivity('', ''); + } + /* variables */ function __toString() { @@ -2189,6 +2214,12 @@ class ResponseThreadEntry extends ThreadEntry { const ENTRY_TYPE = 'R'; + function getActivity() { + return new ThreadActivity( + _S('New Response'), + _S('New response posted')); + } + function getSubject() { return $this->getTitle(); } @@ -2238,6 +2269,12 @@ class NoteThreadEntry extends ThreadEntry { return $this->getBody(); } + function getActivity() { + return new ThreadActivity( + _S('New Internal Note'), + _S('New internal note posted')); + } + static function create($vars, &$errors) { return self::add($vars, $errors); } @@ -2521,4 +2558,46 @@ interface Threadable { function getThread(); function postThreadEntry($type, $vars, $options=array()); } + +/** + * ThreadActivity + * + * Object to thread activity + * + */ +class ThreadActivity implements TemplateVariable { + var $title; + var $desc; + + function __construct($title, $desc) { + $this->title = $title; + $this->desc = $desc; + } + + function getTitle() { + return $this->title; + } + + function getDescription() { + return $this->desc; + } + function asVar() { + return (string) $this->getTitle(); + } + + function getVar($tag) { + if ($tag && is_callable(array($this, 'get'.ucfirst($tag)))) + return call_user_func(array($this, 'get'.ucfirst($tag))); + + return false; + } + + static function getVarScope() { + return array( + 'title' => __('Activity Title'), + 'description' => __('Activity Description'), + ); + } +} + ?>