1<?php
2/**
3 * Tracker - history and notifications
4 *
5 * @link http://www.egroupware.org
6 * @author Ralf Becker <RalfBecker-AT-outdoor-training.de>
7 * @package tracker
8 * @copyright (c) 2006-16 by Ralf Becker <RalfBecker-AT-outdoor-training.de>
9 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
10 * @version $Id$
11 */
12
13use EGroupware\Api;
14use EGroupware\Api\Storage\Customfields;
15
16/**
17 * Tracker - tracking object for the tracker
18 */
19class tracker_tracking extends Api\Storage\Tracking
20{
21	/**
22	 * Application we are tracking (required!)
23	 *
24	 * @var string
25	 */
26	var $app = 'tracker';
27	/**
28	 * Name of the id-field, used as id in the history log (required!)
29	 *
30	 * @var string
31	 */
32	var $id_field = 'tr_id';
33	/**
34	 * Name of the field with the creator id, if the creator of an entry should be notified
35	 *
36	 * @var string
37	 */
38	var $creator_field = 'tr_creator';
39	/**
40	 * Name of the field with the id(s) of assinged users, if they should be notified
41	 *
42	 * @var string
43	 */
44	var $assigned_field = 'tr_assigned';
45	/**
46	 * Translate field-name to 2-char history status
47	 *
48	 * @var array
49	 */
50	var $field2history = array();
51	/**
52	 * Should the user (passed to the track method or current user if not passed) be used as sender or get_config('sender')
53	 *
54	 * @var boolean
55	 */
56	var $prefer_user_as_sender = false;
57	/**
58	 * Instance of the botracker class calling us
59	 *
60	 * @access private
61	 * @var tracker_bo
62	 */
63	var $tracker;
64
65	/**
66	 * Constructor
67	 *
68	 * @param tracker_bo $botracker
69	 * @return tracker_tracking
70	 */
71	function __construct(tracker_bo $botracker, $notification_class=false)
72	{
73		$this->tracker = $botracker;
74		$this->field2history = $botracker->field2history;
75
76		parent::__construct('tracker', $notification_class);	// adding custom fields for tracker
77	}
78
79	/**
80	 * Tracks the changes in one entry $data, by comparing it with the last version in $old
81	 *
82	 * Overridden from parent to hide restricted comments
83	 *
84	 * @param array $data current entry
85	 * @param array $old =null old/last state of the entry or null for a new entry
86	 * @param int $user =null user who made the changes, default to current user
87	 * @param boolean $deleted =null can be set to true to let the tracking know the item got deleted or undeleted
88	 * @param array $changed_fields =null changed fields from ealier call to $this->changed_fields($data,$old), to not compute it again
89	 * @param boolean $skip_notification =false do NOT send any notification
90	 * @return int|boolean false on error, integer number of changes logged or true for new entries ($old == null)
91	 */
92	public function track(array $data,array $old=null,$user=null,$deleted=null,array $changed_fields=null,$skip_notification=false)
93	{
94		$this->user = !is_null($user) ? $user : $GLOBALS['egw_info']['user']['account_id'];
95
96		$changes = true;
97
98		// Hide restricted comments from reply count
99		foreach((array)$data['replies'] as $reply)
100		{
101			if($reply['reply_visible'] != 0)
102			{
103				$data['num_replies']--;
104			}
105		}
106		if ($old && $this->field2history)
107		{
108			// If someone made a restricted comment, hide that from change tracking (notification & history)
109			$old['num_replies'] = $data['num_replies'] - (!$data['reply_message'] || $data['reply_visible'] != 0 ? 0 : 1);
110
111			$changes = $this->save_history($data,$old,$deleted,$changed_fields);
112		}
113		// check if the not tracked field num_replies changed and count that as change to
114		// so new comments without other changes give a notification
115		if (!$changes && $old && $old['num_replies'] != $data['num_replies'])
116		{
117			$changes = true;
118		}
119		// do not run do_notifications if we have no changes, unless there was a restricted comment just made
120		if (($changes || ($data['reply_visible'] != 0)) && !$skip_notification && !$this->do_notifications($data,$old,$deleted,$changes))
121		{
122			$changes = false;
123		}
124		return $changes;
125	}
126
127	/**
128	 * Send an autoreply to the ticket creator or replier by the mailhandler
129	 *
130	 * @param array $data current entry
131	 * @param array $autoreply values for:
132	 *			'reply_text' => Texline to add to the mail message
133	 *			'reply_to' => UserID or email address
134	 * @param array $old =null old/last state of the entry or null for a new entry
135	 */
136	function autoreply($data,$autoreply,$old=null)
137	{
138		if (is_integer($autoreply['reply_to'])) // Mail from a known user
139		{
140			if ($this->notify_current_user)
141			{
142				return; // Already notified while saving
143			}
144			else
145			{
146				$this->notify_current_user = true; // Ensure send_notification() doesn't fail this check
147			}
148			$email = $GLOBALS['egw']->accounts->id2name($this->user,'account_email');
149		}
150		else
151		{
152			$this->notify_current_user = true; // Ensure send_notification() doesn't fail this check
153			$email = $autoreply['reply_to']; // mail from an unknown user (set here, so we need to send a notification)
154		}
155		//error_log(__METHOD__.__LINE__.array2string($autoreply));
156		if ($autoreply['reply_text'])
157		{
158			$data['reply_text'] = $autoreply['reply_text'];
159			$this->ClearBodyCache();
160		}
161		// Send notification to the creator only; assignee, CC etc have been notified already
162		$this->send_notification($data,$old,$email,(is_integer($autoreply['reply_to'])?$data[$this->creator_field]:$this->get_config('lang',$data)));
163	}
164
165	/**
166	 * Send notifications for changed entry
167	 *
168	 * Overridden to hide restricted comments.  Sends restricted first to all but creator, then unrestricted to creator
169	 *
170	 * @internal use only track($data,$old,$user)
171	 * @param array $data current entry
172	 * @param array $old =null old/last state of the entry or null for a new entry
173	 * @param boolean $deleted =null can be set to true to let the tracking know the item got deleted or undelted
174	 * @return boolean true on success, false on error (error messages are in $this->errors)
175	 */
176	public function do_notifications($data,$old,$deleted, $changes)
177	{
178		$skip = $this->get_config('skip_notify',$data,$old);
179		$email_notified = $skip ? $skip : array();
180
181		// Send all to others
182		$creator = $data[$this->creator_field];
183		$creator_field = $this->creator_field;
184		if(!($this->tracker->is_admin($data['tr_tracker'], $creator, true) || $this->tracker->is_technician($data['tr_tracker'], $creator)))
185		{
186			// Notify the creator with full info if they're an admin or technician
187			$this->creator_field = null;
188		}
189
190		// Don't send CC
191		$private = $data['tr_private'];
192		$data['tr_private'] = true;
193
194		// Send notification - $email_notified will be skipped
195		$success = parent::do_notifications($data, $old, $deleted, $email_notified);
196
197		//error_log(__METHOD__.__LINE__." email notified with restricted comments:".array2string($email_notified));
198
199		if(!$changes)
200		{
201			// Only thing that really changed was a restricted comment
202			//error_log(__METHOD__.':'.__LINE__.' Stopping, no other changes');
203			return $success;
204		}
205		// clears the cached notifications body
206		$this->ClearBodyCache();
207
208		// Edit messages
209		foreach((array)$data['replies'] as $key => $reply)
210		{
211			if($reply['reply_visible'] != 0)
212			{
213				unset($data['replies'][$key]);
214			}
215		}
216
217		// Send to creator (if not already notified) && CC
218		if(!($this->tracker->is_admin($data['tr_tracker'], $creator, true) || $this->tracker->is_technician($data['tr_tracker'], $creator)))
219		{
220			$this->creator_field = $creator_field;
221		}
222		$this->tracker->preset_replies[$data['tr_id']] = $data['replies'];
223		$data['tr_private'] = $private;
224		//$already_notified = $email_notified;
225		$ret = $success && parent::do_notifications($data, $old, $deleted, $email_notified);
226		//error_log(__METHOD__.__LINE__." email notified, restricted comments removed:".array2string(array_diff($email_notified,$already_notified)));
227
228		return $ret;
229	}
230
231	/**
232	 * Get a notification-config value
233	 *
234	 * @param string $name
235	 * 	- 'copy' array of email addresses notifications should be copied too, can depend on $data
236	 *  - 'lang' string lang code for copy mail
237	 *  - 'sender' string send email address
238	 * @param array $data current entry
239	 * @param array $old =null old/last state of the entry or null for a new entry
240	 * @return mixed
241	 */
242	function get_config($name,$data,$old=null)
243	{
244		unset($old);	// not used
245
246		$tracker = $data['tr_tracker'];
247
248		$config = $this->tracker->notification[$tracker][$name] ? $this->tracker->notification[$tracker][$name] : $this->tracker->notification[0][$name];
249
250		switch($name)
251		{
252			case 'copy':	// include the tr_cc addresses
253				// If not set for this queue or all queues, default to true
254				$no_external = $this->tracker->notification[$tracker]['no_external'] ?
255					$this->tracker->notification[$tracker]['no_external'] :
256					$this->tracker->notification[0]['no_external'];
257
258				if ($data['tr_private'] || $no_external)
259				{
260					return array();	// no copies for private entries
261				}
262				$config = $config ? preg_split('/, ?/',$config) : array();
263				if ($data['tr_cc'])
264				{
265					$config = array_merge($config,preg_split('/, ?/',$data['tr_cc']));
266				}
267				break;
268			case 'skip_notify':
269				$config = array_merge((array)$config,$data['skip_notify'] ? $data['skip_notify'] : (array)$this->skip_notify);
270				break;
271			case 'reply_to':
272				if (empty($config))	// if no explicit reply_to set in notifications use sender from mail config
273				{
274					$config = $this->tracker->notification[$tracker]['sender'] ?
275						$this->tracker->notification[$tracker]['sender'] :
276						$this->tracker->notification[0]['sender'];
277				}
278				break;
279		}
280		//error_log(__METHOD__.__LINE__.' Name:'.$name.' -> '.array2string($config).' Data:'.array2string($data));
281		return $config;
282	}
283
284	/**
285	 * Get the subject for a given entry, reimplementation for get_subject in Api\Storage\Tracking
286	 *
287	 * Default implementation uses the link-title
288	 *
289	 * @param array $data
290	 * @param array $old
291	 * @return string
292	 */
293	function get_subject($data,$old)
294	{
295		unset($old);	// not used
296
297		return $data['prefix'] . $this->tracker->trackers[$data['tr_tracker']].' #'.$data['tr_id'].': '.$data['tr_summary'];
298	}
299
300	/**
301	 * Get the body of the notification message
302	 * If there is a custom notification message configured, that will be used.  Otherwise, the
303	 * default message will be used.
304	 *
305	 * @param boolean $html_email
306	 * @param array $data
307	 * @param array $old
308	 * @param boolean $integrate_link to have links embedded inside the body
309	 * @param int|string $receiver numeric account_id or email address
310	 * @return string
311	 */
312	function get_body($html_email,$data,$old,$integrate_link = true,$receiver=null)
313	{
314		$notification = $this->tracker->notification[$data['tr_tracker']];
315		$merge = new tracker_merge();
316
317		// Set comments according to data, avoids re-reading from DB
318		if (isset($data['replies'])) $merge->set_comments($data['tr_id'], $data['replies']);
319
320		if(trim(strip_tags($notification['message'])) == '' || !$notification['use_custom'])
321		{
322			$notification['message'] = $this->tracker->notification[0]['message'];
323		}
324		if(trim(strip_tags($notification['signature'])) == '' || !$notification['use_signature'])
325		{
326			$notification['signature'] = $this->tracker->notification[0]['signature'];
327		}
328		if(!$notification['use_signature'] && !$this->tracker->notification[0]['use_signature']) $notification['signature'] = '';
329
330		// If no signature set, use the global one
331		if(!$notification['signature'])
332		{
333			$notification['signature'] = parent::get_signature($data,$old,$receiver);
334		}
335		else
336		{
337			$error = null;
338			$notification['signature'] = $merge->merge_string($notification['signature'], array($data['tr_id']), $error, 'text/html');
339		}
340
341		if((!$notification['use_custom'] && !$this->tracker->notification[0]['use_custom']) || !$notification['message'])
342		{
343			// Always use text mode for text tickets, HTML for HTML tickets
344			$html = $this->html_content_allow;
345			$this->html_content_allow = $data['tr_edit_mode'] !== 'ascii';
346
347			$body = parent::get_body($html_email,$data,$old,$integrate_link,$receiver).($html_email?"<br />\n":"\n").
348				$notification['signature'];
349
350			$this->html_content_allow = $html;
351			return $body;
352		}
353
354		$message = $this->sanitize_custom_message($notification['message'], $receiver);
355		$message = $merge->merge_string($message, array($data['tr_id']), $error, 'text/html');
356		if(strpos($notification['message'], '{{signature}}') === False)
357		{
358			$message.=($html_email?"<br />\n":"\n").
359				$notification['signature'];
360		}
361		if($error)
362		{
363			error_log($error);
364			return parent::get_body($html_email,$data,$old,$integrate_link,$receiver)."\n".$notification['signature'];
365		}
366		return $html_email ? $message : Api\Mail\Html::convertHTMLToText(Api\Html::purify($message), false, true, true);
367	}
368
369	/**
370	 * Override parent to return nothing, it's taken care of in get_body()
371	 *
372	 * @see get_body()
373	 */
374	protected function get_signature($data,$old,$receiver)
375	{
376		unset($data,$old,$receiver);	// not used
377
378		return false;
379	}
380
381	/**
382	 * Get the modified / new message (1. line of mail body) for a given entry, can be reimplemented
383	 *
384	 * @param array $data
385	 * @param array $old
386	 * @return array (of strings) for multiline messages
387	 */
388	function get_message($data,$old)
389	{
390		if($data['message']) return $data['message'];
391
392		if ($data['reply_text'])
393		{
394			$r[] = $data['reply_text'];
395			$r[] = '---';// this is wanted for separation of reply_text to status/creation text
396		}
397
398		if (!$data['tr_modified'] || !$old)
399		{
400			$r[] = lang('New ticket submitted by %1 at %2',
401				Api\Accounts::username($data['tr_creator']),
402				$this->datetime($data['tr_created_servertime']));
403			return $r;
404		}
405		$r[] = lang('Ticket modified by %1 at %2',
406			$data['tr_modifier'] ? Api\Accounts::username($data['tr_modifier']) : lang('Tracker'),
407			$this->datetime($data['tr_modified_servertime']));
408		return $r;
409	}
410
411	/**
412	 * Get the details of an entry
413	 *
414	 * @param array $data
415	 * @param int|string $receiver numeric account_id or email address
416	 * @return array of details as array with values for keys 'label','value','type'
417	 */
418	function get_details($data, $receiver)
419	{
420		static $cats=null,$versions=null,$statis=null,$priorities=null,$resolutions=null;
421		if (!$cats)
422		{
423			$cats = $this->tracker->get_tracker_labels('cat',$data['tr_tracker']);
424			$versions = $this->tracker->get_tracker_labels('version',$data['tr_tracker']);
425			$statis = $this->tracker->get_tracker_stati($data['tr_tracker']);
426			$priorities = $this->tracker->get_tracker_priorities($data['tr_tracker']);
427			$resolutions = $this->tracker->get_tracker_labels('resolution',$data['tr_tracker']);
428		}
429		if ($data['tr_assigned'])
430		{
431			foreach($data['tr_assigned'] as $uid)
432			{
433				$assigned[] = Api\Accounts::username($uid);
434			}
435			$assigned = implode(', ',$assigned);
436		}
437/*
438		if ($data['reply_text'])
439		{
440			$details['reply_text'] = array(
441				'value' => $data['reply_text'],
442				'type' => 'message',
443			);
444		}
445*/
446		$detail_fields = array(
447			'tr_tracker'     => $this->tracker->trackers[$data['tr_tracker']],
448			'cat_id'         => $cats[$data['cat_id']],
449			'tr_version'     => $versions[$data['tr_version']],
450			'tr_startdate'   => $this->datetime($data['tr_startdate']),
451			'tr_duedate'     => $this->datetime($data['tr_duedate']),
452			'tr_status'      => lang($statis[$data['tr_status']]),
453			'tr_resolution'  => lang($resolutions[$data['tr_resolution']]),
454			'tr_completion'  => (int)$data['tr_completion'].'%',
455			'tr_priority'    => lang($priorities[$data['tr_priority']]),
456			'tr_creator'     => Api\Accounts::username($data['tr_creator']),
457			'tr_created'     => $this->datetime($data['tr_created']),
458			'tr_assigned'	 => !$data['tr_assigned'] ? lang('Not assigned') : $assigned,
459			'tr_cc'			 => $data['tr_cc'],
460			// The layout of tr_summary should NOT be changed in order for
461			// tracker.tracker_mailhandler.get_ticketId() to work!
462			'tr_summary'     => '#'.$data['tr_id'].' - '.$data['tr_summary'],
463		);
464
465		// Don't show start date / due date if disabled or not set
466		$config = Api\Config::read('tracker');
467		if(!$config['show_dates'])
468		{
469			unset($detail_fields['tr_startdate']);
470			unset($detail_fields['tr_duedate']);
471		}
472		if(!$data['tr_startdate']) unset($detail_fields['tr_startdate']);
473		if(!$data['tr_duedate']) unset($detail_fields['tr_duedate']);
474
475		foreach($detail_fields as $name => $value)
476		{
477			$details[$name] = array(
478				'label' => lang($this->tracker->field2label[$name]),
479				'value' => $value,
480			);
481			if ($name == 'tr_summary') $details[$name]['type'] = 'summary';
482		}
483		// add custom fields for given type
484		$details += $this->get_customfields($data, $data['tr_tracker'], $receiver);
485
486		if ($data['replies']) //$data['reply_message'] && !$data['reply_visible'])
487		{
488			// At least one comment was made
489			$reply = $data['replies'][0];
490			$details[] = array(
491				'type' => 'message',
492				'label' => lang('Comment by %1 at %2:',$reply['reply_creator'] ? Api\Accounts::username($reply['reply_creator']) : lang('Tracker'),$this->datetime($reply['reply_servertime'])),
493				'value' => ' '
494			);
495			$details[] = array(
496				'type' => 'reply',
497				'value' => $data['tr_edit_mode'] == 'ascii' ?
498						preg_replace("@\n\n+@", "\n", $reply['reply_message']) :
499						preg_replace("@\n\n+|<br ?/?>\n?<br ?/?>@", "<br>", $reply['reply_message'])
500			);
501			$n = 2;
502		}
503		$details[] = array(
504			'value' => lang('Description'),
505			'type' => 'summary'
506		);
507		$details['tr_description'] = array(
508			'value' => $data['tr_edit_mode'] == 'ascii' ? htmlspecialchars_decode($data['tr_description']) : $data['tr_description'],
509			'type'  => 'multiline',
510		);
511		if ($data['replies'])
512		{
513			foreach($data['replies'] as $reply_index => $reply)
514			{
515				if(!$reply['reply_message']) continue;
516				$reply['reply_message'] = $data['tr_edit_mode'] == 'ascii' ?
517						preg_replace("@\n\n+@", "\n", $reply['reply_message']) :
518						preg_replace("@\n\n+|<br ?/?>\n?<br ?/?>@", "<br>", $reply['reply_message']);
519				$msg = array(	// first reply need to be checked against old to marked modified for new
520					'value' => lang('Comment by %1 at %2:',$reply['reply_creator'] ? Api\Accounts::username($reply['reply_creator']) : lang('Tracker'),
521						$this->datetime($reply['reply_servertime'])),
522					'type'  => 'reply',
523				);
524				if(!$reply_index)
525				{
526					$details['replies'] = $msg;
527				}
528				else
529				{
530					$details[] = $msg;
531				}
532				$details[] = array(
533					'value' => $reply['reply_message'],
534					'type'  => 'multiline',
535				);
536			}
537		}
538		return $details;
539	}
540
541	/**
542	 * Override to extend permission so tracker_merge can use it
543	 */
544	public function get_link($data,$old,$allow_popup=false,$receiver=null)
545	{
546		return parent::get_link($data,$old,$allow_popup,$receiver);
547	}
548
549	/**
550	 * Compute changes between new and old data
551	 *
552	 * Reimplemented to cope with some tracker specialties:
553	 * - tr_completion is postfixed with a percent
554	 *
555	 * @param array $data
556	 * @param array $old =null
557	 * @return array of keys with different values in $data and $old
558	 */
559	public function changed_fields(array $data,array $old=null)
560	{
561		$changed = parent::changed_fields($data, $old);
562
563		// for tr_completion ignore percent postfix
564		if (($k = array_search('tr_completion', $changed)) !== false &&
565			(int)$data['tr_completion'] === (int)$old['tr_completion'])
566		{
567			unset($changed[$k]);
568		}
569		return $changed;
570	}
571}
572