diff --git a/include/ajax.content.php b/include/ajax.content.php index 9ea0d7d1431bdf58aac6b2ffada47acb7e4585bd..dc1bc9ab1f9678913ee1581e3ea672d879d53671 100644 --- a/include/ajax.content.php +++ b/include/ajax.content.php @@ -119,6 +119,11 @@ class ContentAjaxAPI extends AjaxController { switch ($type) { case 'none': break; + case 'agent': + if (!($staff = Staff::lookup($id))) + Http::response(404, 'No such staff member'); + echo Format::viewableImages($staff->getSignature()); + break; case 'mine': echo Format::viewableImages($thisstaff->getSignature()); break; diff --git a/include/class.mailer.php b/include/class.mailer.php index c398df9f5ccf26f8bca4a8d16a21009fbc52cdf6..0abb652469fa8d6491dcd5efa1f37ac5bc139664 100644 --- a/include/class.mailer.php +++ b/include/class.mailer.php @@ -361,12 +361,12 @@ class Mailer { 'References' => $options['thread']->getEmailReferences() ); } - elseif ($parent = $options['thread']->getParent()) { + elseif ($original = $options['thread']->findOriginalEmailMessage()) { // Use the parent item as the email information source. This // will apply for staff replies $headers += array( - 'In-Reply-To' => $parent->getEmailMessageId(), - 'References' => $parent->getEmailReferences(), + 'In-Reply-To' => $original->getEmailMessageId(), + 'References' => $original->getEmailReferences(), ); } diff --git a/include/class.orm.php b/include/class.orm.php index 58aeaede544bc124cc7e1cf6a905d6baad12871c..38e1d931d49db8057591de5d9d250e911519d3f0 100644 --- a/include/class.orm.php +++ b/include/class.orm.php @@ -71,34 +71,39 @@ class ModelMeta implements ArrayAccess { } function processJoin(&$j) { + $constraint = array(); if (isset($j['reverse'])) { list($fmodel, $key) = explode('.', $j['reverse']); $info = $fmodel::$meta['joins'][$key]; - $constraint = array(); if (!is_array($info['constraint'])) throw new OrmConfigurationException(sprintf(__( // `reverse` here is the reverse of an ORM relationship '%s: Reverse does not specify any constraints'), $j['reverse'])); foreach ($info['constraint'] as $foreign => $local) { - list(,$field) = explode('.', $local); - $constraint[$field ?: $local] = "$fmodel.$foreign"; + list($L,$field) = is_array($local) ? $local : explode('.', $local); + $constraint[$field ?: $L] = array($fmodel, $foreign); } - $j['constraint'] = $constraint; if (!isset($j['list'])) $j['list'] = true; if (!isset($j['null'])) // By default, reverse releationships can be empty lists $j['null'] = true; } - // XXX: Make this better (ie. composite keys) - foreach ($j['constraint'] as $local => $foreign) { - list($class, $field) = explode('.', $foreign); + else { + foreach ($j['constraint'] as $local => $foreign) { + list($class, $field) = $constraint[$local] + = explode('.', $foreign); + } + } + foreach ($constraint as $local => $foreign) { + list($class, $field) = $foreign; if ($local[0] == "'" || $field[0] == "'" || !class_exists($class)) continue; - $j['fkey'] = array($class, $field); + $j['fkey'] = $foreign; $j['local'] = $local; } + $j['constraint'] = $constraint; } function offsetGet($field) { @@ -178,13 +183,13 @@ class VerySimpleModel { elseif (isset($j['fkey'])) { $criteria = array(); foreach ($j['constraint'] as $local => $foreign) { - list($klas,$F) = explode('.', $foreign); + list($klas,$F) = $foreign; if (class_exists($klas)) $class = $klas; if ($local[0] == "'") { $criteria[$F] = trim($local,"'"); } - elseif ($foreign[0] == "'") { + elseif ($F[0] == "'") { // Does not affect the local model continue; } @@ -535,14 +540,20 @@ class SqlFunction { $this->args = array_slice(func_get_args(), 1); } + function input($what, $compiler, $model) { + if ($what instanceof SqlFunction) + $A = $what->toSql($compiler, $model); + elseif ($what instanceof Q) + $A = $compiler->compileQ($what, $model); + else + $A = $compiler->input($what); + return $A; + } + function toSql($compiler, $model=false, $alias=false) { $args = array(); foreach ($this->args as $A) { - if ($A instanceof SqlFunction) - $A = $A->toSql($compiler, $model); - else - $A = $compiler->input($A); - $args[] = $A; + $args[] = $this->input($A, $compiler, $model); } return sprintf('%s(%s)%s', $this->func, implode(',', $args), $alias && $this->alias ? ' AS '.$compiler->quote($this->alias) : ''); @@ -562,6 +573,40 @@ class SqlFunction { } } +class SqlCase extends SqlFunction { + var $cases = array(); + var $else = false; + + static function N() { + return new static('CASE'); + } + + function when($expr, $result) { + $this->cases[] = array($expr, $result); + return $this; + } + function otherwise($result) { + $this->else = $result; + return $this; + } + + function toSql($compiler, $model=false, $alias=false) { + $cases = array(); + foreach ($this->cases as $A) { + list($expr, $result) = $A; + $expr = $this->input($expr, $compiler, $model); + $result = $this->input($result, $compiler, $model); + $cases[] = "WHEN {$expr} THEN {$result}"; + } + if ($this->else) { + $else = $this->input($this->else, $compiler, $model); + $cases[] = "ELSE {$else}"; + } + return sprintf('CASE %s END%s', implode(' ', $cases), + $alias && $this->alias ? ' AS '.$compiler->quote($this->alias) : ''); + } +} + class SqlExpr extends SqlFunction { function __construct($args) { $this->args = $args; @@ -687,7 +732,12 @@ class SqlAggregate extends SqlFunction { // For DISTINCT, require a field specification — not a relationship // specification. - list($field, $rmodel) = $compiler->getField($this->expr, $model, $options); + $E = $this->expr; + if ($E instanceof SqlFunction) { + $field = $E->toSql($compiler, $model, $alias); + } + else { + list($field, $rmodel) = $compiler->getField($E, $model, $options); if ($this->distinct) { $pk = false; foreach ($rmodel::$meta['pk'] as $f) { @@ -708,6 +758,7 @@ class SqlAggregate extends SqlFunction { } } } + } return sprintf('%s(%s%s)%s', $this->func, $this->distinct ? 'DISTINCT ' : '', $field, @@ -1455,23 +1506,19 @@ class SqlCompiler { $operator = static::$operators['exact']; if (!isset($options['table'])) { $field = array_pop($parts); - if (array_key_exists($field, static::$operators)) { + if (isset(static::$operators[$field])) { $operator = static::$operators[$field]; $field = array_pop($parts); } } $path = array(); - - // Determine the alias for the root model table - $alias = (isset($this->joins[''])) - ? $this->joins['']['alias'] - : $this->quote($model::$meta['table']); + $rootModel = $model; // Call pushJoin for each segment in the join path. A new JOIN // fragment will need to be emitted and/or cached - $self = $this; - $push = function($p, $path, $extra=false) use (&$model, $self) { + $joins = array(); + $push = function($p, $path, $model) use (&$joins) { $model::_inspect(); if (!($info = $model::$meta['joins'][$p])) { throw new OrmException(sprintf( @@ -1479,27 +1526,38 @@ class SqlCompiler { $model, $p)); } $crumb = implode('__', $path); - $path[] = $p; - $tip = implode('__', $path); - $alias = $self->pushJoin($crumb, $tip, $model, $info, $extra); + $tip = ($crumb) ? "{$crumb}__{$p}" : $p; + $joins[] = array($crumb, $tip, $model, $info); // Roll to foreign model - foreach ($info['constraint'] as $local => $foreign) { - list($model, $f) = explode('.', $foreign); - if (class_exists($model)) - break; - } - return array($alias, $f); + return $info['fkey']; }; - foreach ($parts as $i=>$p) { - list($alias) = $push($p, $path, @$options['constraint']); + foreach ($parts as $p) { + list($model) = $push($p, $path, $model); $path[] = $p; } // If comparing a relationship, join the foreign table // This is a comparison with a relationship — use the foreign key if (isset($model::$meta['joins'][$field])) { - list($alias, $field) = $push($field, $path, @$options['constraint']); + list($model, $field) = $push($field, $path, $model); + } + + // Add the conststraint as the last arg to the last join + if (isset($options['constraint'])) { + $joins[count($joins)-1][] = $options['constraint']; + } + + // Apply the joins list to $this->pushJoin + foreach ($joins as $A) { + $alias = call_user_func_array(array($this, 'pushJoin'), $A); + } + + if (!isset($alias)) { + // Determine the alias for the root model table + $alias = (isset($this->joins[''])) + ? $this->joins['']['alias'] + : $this->quote($rootModel::$meta['table']); } if (isset($options['table']) && $options['table']) @@ -1550,8 +1608,8 @@ class SqlCompiler { // coordination between the data returned from the database (where // table alias is available) and the corresponding data. $T = array('alias' => $alias); - $this->joins[$path] = &$T; - $T['sql'] = $this->compileJoin($tip, $model, $alias, $info, $constraint); + $this->joins[$path] = $T; + $this->joins[$path]['sql'] = $this->compileJoin($tip, $model, $alias, $info, $constraint); return $alias; } @@ -1712,12 +1770,6 @@ class DbEngine { class MySqlCompiler extends SqlCompiler { - // Consts for ::input() - const SLOT_JOINS = 1; - const SLOT_WHERE = 2; - - protected $input_join_count = 0; - static $operators = array( 'exact' => '%1$s = %2$s', 'contains' => array('self', '__contains'), @@ -1803,21 +1855,21 @@ class MySqlCompiler extends SqlCompiler { else $table = $this->quote($model::$meta['table']); foreach ($info['constraint'] as $local => $foreign) { - list($rmodel, $right) = explode('.', $foreign); + list($rmodel, $right) = $foreign; // Support a constant constraint with // "'constant'" => "Model.field_name" if ($local[0] == "'") { $constraints[] = sprintf("%s.%s = %s", $alias, $this->quote($right), - $this->input(trim($local, '\'"'), self::SLOT_JOINS) + $this->input(trim($local, '\'"')) ); } // Support local constraint // field_name => "'constant'" - elseif ($foreign[0] == "'" && !$right) { + elseif ($rmodel[0] == "'" && !$right) { $constraints[] = sprintf("%s.%s = %s", $table, $this->quote($local), - $this->input(trim($foreign, '\'"'), self::SLOT_JOINS) + $this->input(trim($rmodel, '\'"')) ); } else { @@ -1829,7 +1881,7 @@ class MySqlCompiler extends SqlCompiler { } // Support extra join constraints if ($extra instanceof Q) { - $constraints[] = $this->compileQ($extra, $model, self::SLOT_JOINS); + $constraints[] = $this->compileQ($extra, $model); } if (!isset($rmodel)) $rmodel = $model; @@ -1838,9 +1890,9 @@ class MySqlCompiler extends SqlCompiler { // XXX: Support parameters from the nested query ? $rmodel::getQuery($this) : $this->quote($rmodel::$meta['table']); - $base = "$join$table $alias"; + $base = "{$join}{$table} {$alias}"; if ($constraints) - $base .= ' ON ('.implode(' AND ', $constraints).')'; + $base .= ' ON ('.implode(' AND ', $constraints).')'; return $base; } @@ -1853,11 +1905,6 @@ class MySqlCompiler extends SqlCompiler { * Parameters: * $what - (mixed) value to be sent to the database. No escaping is * necessary. Pass a raw value here. - * $slot - (int) clause location of the input in compiled SQL statement. - * Currently, SLOT_JOINS and SLOT_WHERE is supported. SLOT_JOINS - * inputs are inserted ahead of the SLOT_WHERE inputs as the joins - * come logically before the where claused in the finalized - * statement. * * Returns: * (string) token to be placed into the compiled SQL statement. For @@ -1876,16 +1923,8 @@ class MySqlCompiler extends SqlCompiler { return 'NULL'; } else { - switch ($slot) { - case self::SLOT_JOINS: - // This should be inserted before the WHERE inputs - array_splice($this->params, $this->input_join_count++, 0, - array($what)); - break; - default: - $this->params[] = $what; - } - return '?'; + $this->params[] = $what; + return ':'.(count($this->params)); } } @@ -2193,7 +2232,7 @@ class MySqlCompiler extends SqlCompiler { } } -class MysqlExecutor { +class MySqlExecutor { var $stmt; var $fields = array(); @@ -2214,17 +2253,28 @@ class MysqlExecutor { return $this->map; } + function fixupParams() { + $self = $this; + $params = array(); + $sql = preg_replace_callback('/:(\d+)/', function($m) use ($self, &$params) { + $params[] = $self->params[$m[1]-1]; + return '?'; + }, $this->sql); + return array($sql, $params); + } + function _prepare() { $this->execute(); $this->_setup_output(); } function execute() { - if (!($this->stmt = db_prepare($this->sql))) + list($sql, $params) = $this->fixupParams(); + if (!($this->stmt = db_prepare($sql))) throw new InconsistentModelException( - 'Unable to prepare query: '.db_error().' '.$this->sql); - if (count($this->params)) - $this->_bind($this->params); + 'Unable to prepare query: '.db_error().' '.$sql); + if (count($params)) + $this->_bind($params); if (!$this->stmt->execute() || ! $this->stmt->store_result()) { throw new OrmException('Unable to execute query: ' . $this->stmt->error); } @@ -2335,9 +2385,8 @@ class MysqlExecutor { function __toString() { $self = $this; - $x = 0; - return preg_replace_callback('/\?/', function($m) use ($self, &$x) { - $p = $self->params[$x++]; + return preg_replace_callback('/:(\d+)/', function($m) use ($self) { + $p = $self->params[$m[1]-1]; return db_real_escape($p, is_string($p)); }, $this->sql); } diff --git a/include/class.role.php b/include/class.role.php index b2bcd2c97d0813d7cff5fd03d854bbf607cadeb1..ce4256c5ba75da5a0c67eac8b51319a040758562 100644 --- a/include/class.role.php +++ b/include/class.role.php @@ -310,9 +310,15 @@ class RolePermission { return static::$_permissions; } - static function register($group, $perms) { - static::$_permissions[$group] = array_merge( - static::$_permissions[$group] ?: array(), $perms); + static function register($group, $perms, $prepend=false) { + if ($prepend) { + static::$_permissions[$group] = array_merge( + $perms, static::$_permissions[$group] ?: array()); + } + else { + static::$_permissions[$group] = array_merge( + static::$_permissions[$group] ?: array(), $perms); + } } } ?> diff --git a/include/class.thread.php b/include/class.thread.php index 3cf9308025ffed40b4ca3744dd538c16d9e4715f..2b1d8bec7963775e21de4a6e4a67c2c0ede44286 100644 --- a/include/class.thread.php +++ b/include/class.thread.php @@ -16,6 +16,7 @@ **********************************************************************/ include_once(INCLUDE_DIR.'class.ticket.php'); include_once(INCLUDE_DIR.'class.draft.php'); +include_once(INCLUDE_DIR.'class.role.php'); //Ticket thread. class Thread extends VerySimpleModel { @@ -82,6 +83,7 @@ class Thread extends VerySimpleModel { 'has_attachments' => SqlAggregate::COUNT('attachments', false, new Q(array('attachments__inline'=>0))) )); + $base->exclude(array('flags__hasbit'=>ThreadEntry::FLAG_HIDDEN)); if ($criteria) $base->filter($criteria); return $base; @@ -398,7 +400,8 @@ class ThreadEntry extends VerySimpleModel { static $meta = array( 'table' => THREAD_ENTRY_TABLE, 'pk' => array('id'), - 'select_related' => array('staff', 'user'), + 'select_related' => array('staff', 'user', 'email_info'), + 'ordering' => array('created'), 'joins' => array( 'thread' => array( 'constraint' => array('thread_id' => 'Thread.id'), @@ -430,12 +433,24 @@ class ThreadEntry extends VerySimpleModel { ); const FLAG_ORIGINAL_MESSAGE = 0x0001; + const FLAG_EDITED = 0x0002; + const FLAG_HIDDEN = 0x0004; + const FLAG_GUARDED = 0x0008; // No replace on edit + + const PERM_EDIT = 'thread.edit'; var $_headers; var $_thread; var $_actions; var $_attachments; + static protected $perms = array( + self::PERM_EDIT => array( + 'title' => /* @trans */ 'Edit Thread', + 'desc' => /* @trans */ 'Ability to edit thread items of other agents', + ), + ); + function postEmail($mailinfo) { if (!($thread = $this->getThread())) // Kind of hard to continue a discussion without a thread ... @@ -457,8 +472,7 @@ class ThreadEntry extends VerySimpleModel { } function getParent() { - if ($this->getPid()) - return ThreadEntry::lookup($this->getPid()); + return $this->parent; } function getType() { @@ -565,6 +579,21 @@ class ThreadEntry extends VerySimpleModel { return $recipients; } + /** + * Recurse through the ancestry of this thread entry to find the first + * thread entry which cites a email Message-ID field. + * + * Returns: + * <ThreadEntry> or null if neither this thread entry nor any of its + * ancestry contains an email header with an email Message-ID header. + */ + function findOriginalEmailMessage() { + $P = $this; + while (!$P->getEmailMessageId() + && ($P = $P->getParent())); + return $P; + } + function getUIDFromEmailReference($ref) { $info = unpack('Vtid/Vuid', @@ -1248,8 +1277,14 @@ class ThreadEntry extends VerySimpleModel { self::$action_registry[$group][$action::getId()] = $action; } + + static function getPermissions() { + return self::$perms; + } } +RolePermission::register(/* @trans */ 'Tickets', ThreadEntry::getPermissions()); + class ThreadEntryBody /* extends SplString */ { @@ -1734,7 +1769,7 @@ abstract class ThreadEntryAction { static $id; // Unique identifier used for plumbing static $icon = 'cog'; - var $thread; + var $entry; function getName() { $class = get_class($this); @@ -1751,13 +1786,13 @@ abstract class ThreadEntryAction { } function __construct(ThreadEntry $thread) { - $this->thread = $thread; + $this->entry = $thread; } abstract function trigger(); function getTicket() { - return $this->thread->getTicket(); + return $this->entry->getObject(); } function isEnabled() { @@ -1791,8 +1826,8 @@ abstract class ThreadEntryAction { function getAjaxUrl($dialog=false) { return sprintf('%stickets/%d/thread/%d/%s', $dialog ? '#' : 'ajax.php/', - $this->thread->getThread()->getObjectId(), - $this->thread->getId(), + $this->entry->getThread()->getObjectId(), + $this->entry->getId(), static::getId() ); } diff --git a/include/class.thread_actions.php b/include/class.thread_actions.php index ce6ac82469df253fe5aecb942759de7f4a814252..30ddc810c056e7edfee8949820f4f2665d9501a3 100644 --- a/include/class.thread_actions.php +++ b/include/class.thread_actions.php @@ -23,14 +23,188 @@ class TEA_ShowEmailHeaders extends ThreadEntryAction { static $name = /* trans */ 'View Email Headers'; static $icon = 'envelope'; - function isEnabled() { + function isVisible() { global $thisstaff; + if (!$this->entry->getEmailHeader()) + return false; + return $thisstaff && $thisstaff->isAdmin(); } + function getJsStub() { + return sprintf("$.dialog('%s');", + $this->getAjaxUrl() + ); + } + + function trigger() { + switch ($_SERVER['REQUEST_METHOD']) { + case 'GET': + return $this->trigger__get(); + } + } + + private function trigger__get() { + $headers = $this->entry->getEmailHeader(); + + include STAFFINC_DIR . 'templates/thread-email-headers.tmpl.php'; + } +} +ThreadEntry::registerAction(/* trans */ 'E-Mail', 'TEA_ShowEmailHeaders'); + +class TEA_EditThreadEntry extends ThreadEntryAction { + static $id = 'edit'; + static $name = /* trans */ 'Edit'; + static $icon = 'pencil'; + function isVisible() { - return (bool) $this->thread->getEmailHeader(); + // Can't edit system posts + return ($this->entry->staff_id || $this->entry->user_id) + && $this->entry->type != 'R' && $this->isEnabled(); + } + + function isEnabled() { + global $thisstaff; + + $T = $this->entry->getThread()->getObject(); + // You can edit your own posts or posts by your department members + // if your a manager, or everyone's if your an admin + return $thisstaff && ( + $thisstaff->getId() == $this->entry->staff_id + || ($T instanceof Ticket + && $T->getDept()->getManagerId() == $thisstaff->getId() + ) + || ($T instanceof Ticket + && $thisstaff->getRole($T->getDeptId())->hasPerm(ThreadEntry::PERM_EDIT) + ) + ); + } + + function getJsStub() { + return sprintf(<<<JS +var url = '%s'; +$.dialog(url, [201], function(xhr, resp) { + var json = JSON.parse(resp); + if (!json || !json.thread_id) + return; + $('#thread-id-'+json.thread_id) + .attr('id', 'thread-id-' + json.new_id) + .find('div') + .html(json.body) + .closest('td') + .effect('highlight') +}, {size:'large'}); +JS + , $this->getAjaxUrl()); + } + + + function trigger() { + switch ($_SERVER['REQUEST_METHOD']) { + case 'GET': + return $this->trigger__get(); + case 'POST': + return $this->trigger__post(); + } + } + + protected function trigger__get() { + global $cfg, $thisstaff; + + $poster = $this->entry->getStaff(); + + include STAFFINC_DIR . 'templates/thread-entry-edit.tmpl.php'; + } + + function updateEntry($guard=false) { + $old = $this->entry; + $type = ($old->format == 'html') + ? 'HtmlThreadEntryBody' : 'TextThreadEntryBody'; + $new = new $type($_POST['body']); + + if ($new->getClean() == $old->body) + // No update was performed + return $old; + + $entry = ThreadEntry::create(array( + // Copy most information from the old entry + 'poster' => $old->poster, + 'userId' => $old->user_id, + 'staffId' => $old->staff_id, + 'type' => $old->type, + 'threadId' => $old->thread_id, + + // Connect the new entry to be a child of the previous + 'pid' => $old->id, + + // Add in new stuff + 'title' => $_POST['title'], + 'body' => $new, + 'ip_address' => $_SERVER['REMOTE_ADDR'], + )); + + if (!$entry) + return false; + + // Note, anything that points to the $old entry as PID should remain + // that way for email header lookups and such to remain consistent + + if ($old->flags & ThreadEntry::FLAG_EDITED + and !($old->flags & ThreadEntry::FLAG_GUARDED) + ) { + // Replace previous edit -------------------------- + $original = $old->getParent(); + // Drop the previous edit, and base this edit off the original + $old->delete(); + $old = $original; + } + + // Mark the new entry as edited (but not hidden) + $entry->flags = ($old->flags & ~ThreadEntry::FLAG_HIDDEN) + | ThreadEntry::FLAG_EDITED; + + // Guard against deletes on future edit if requested. This is done + // if an email was triggered by the last edit. In such a case, it + // should not be replace by a subsequent edit. + if ($guard) + $entry->flags |= ThreadEntry::FLAG_GUARDED; + + // Sort in the same place in the thread — XXX: Add a `sequence` id + $entry->created = $old->created; + $entry->updated = SqlFunction::NOW(); + $entry->save(); + + // Hide the old entry from the object thread + $old->flags |= ThreadEntry::FLAG_HIDDEN; + $old->save(); + + return $entry; + } + + protected function trigger__post() { + global $thisstaff; + + if (!($entry = $this->updateEntry())) + return $this->trigger__get(); + + Http::response('201', JsonDataEncoder::encode(array( + 'thread_id' => $this->entry->id, + 'new_id' => $entry->id, + 'body' => $entry->getBody()->toHtml(), + ))); + } +} +ThreadEntry::registerAction(/* trans */ 'Manage', 'TEA_EditThreadEntry'); + +class TEA_OrigThreadEntry extends ThreadEntryAction { + static $id = 'previous'; + static $name = /* trans */ 'View History'; + static $icon = 'copy'; + + function isVisible() { + // Can't edit system posts + return $this->entry->flags & ThreadEntry::FLAG_EDITED; } function getJsStub() { @@ -47,9 +221,113 @@ class TEA_ShowEmailHeaders extends ThreadEntryAction { } private function trigger__get() { - $headers = $this->thread->getEmailHeader(); + $entry = $this->entry->getParent(); + if (!$entry) + Http::response(404, 'No history for this entry'); + include STAFFINC_DIR . 'templates/thread-entry-view.tmpl.php'; + } +} +ThreadEntry::registerAction(/* trans */ 'Manage', 'TEA_OrigThreadEntry'); - include STAFFINC_DIR . 'templates/thread-email-headers.tmpl.php'; +class TEA_EditAndResendThreadEntry extends TEA_EditThreadEntry { + static $id = 'edit_resend'; + static $name = /* trans */ 'Edit and Resend'; + static $icon = 'reply-all'; + + function isVisible() { + // Can only resend replies + return $this->entry->staff_id && $this->entry->type == 'R' + && $this->isEnabled(); + } + + protected function trigger__post() { + $resend = @$_POST['commit'] == 'resend'; + + if (!($entry = $this->updateEntry($resend))) + return $this->trigger__get(); + + if (@$_POST['commit'] == 'resend') + $this->resend($entry); + + Http::response('201', JsonDataEncoder::encode(array( + 'thread_id' => $this->entry->id, + 'new_id' => $entry->id, + 'body' => $entry->getBody()->toHtml(), + ))); + } + + function resend($response) { + global $cfg, $thisstaff; + + $vars = $_POST; + $ticket = $response->getThread()->getObject(); + + $dept = $ticket->getDept(); + $poster = $response->getStaff(); + + if ($thisstaff && $vars['signature'] == 'mine') + $signature = $thisstaff->getSignature(); + elseif ($poster && $vars['signature'] == 'theirs') + $signature = $poster->getSignature(); + elseif ($vars['signature'] == 'dept' && $dept && $dept->isPublic()) + $signature = $dept->getSignature(); + else + $signature = ''; + + $variables = array( + 'response' => $response, + 'signature' => $signature, + 'staff' => $response->getStaff(), + 'poster' => $response->getStaff()); + $options = array('thread' => $response); + + if (($email=$dept->getEmail()) + && ($tpl = $dept->getTemplate()) + && ($msg=$tpl->getReplyMsgTemplate()) + ) { + $msg = $ticket->replaceVars($msg->asArray(), + $variables + array('recipient' => $ticket->getOwner())); + + $attachments = $cfg->emailAttachments() + ? $response->getAttachments() : array(); + $email->send($ticket->getOwner(), $msg['subj'], $msg['body'], + $attachments, $options); + } + // TODO: Add an option to the dialog + $ticket->notifyCollaborators($response, array('signature' => $signature)); } } -ThreadEntry::registerAction(/* trans */ 'E-Mail', 'TEA_ShowEmailHeaders'); +ThreadEntry::registerAction(/* trans */ 'Manage', 'TEA_EditAndResendThreadEntry'); + +class TEA_ResendThreadEntry extends TEA_EditAndResendThreadEntry { + static $id = 'resend'; + static $name = /* trans */ 'Resend'; + static $icon = 'reply-all'; + + function isVisible() { + // Can only resend replies + return $this->entry->staff_id && $this->entry->type == 'R' + && !parent::isEnabled(); + } + function isEnabled() { + return true; + } + + protected function trigger__get() { + global $cfg, $thisstaff; + + $poster = $this->entry->getStaff(); + + include STAFFINC_DIR . 'templates/thread-entry-resend.tmpl.php'; + } + + protected function trigger__post() { + $resend = @$_POST['commit'] == 'resend'; + + if (@$_POST['commit'] == 'resend') + $this->resend($this->entry); + + Http::response('201', 'Okee dokey'); + } +} +ThreadEntry::registerAction(/* trans */ 'Manage', 'TEA_ResendThreadEntry'); diff --git a/include/class.ticket.php b/include/class.ticket.php index 30a5df7fd556d85d55074691e093289992d5aeab..2a14829dc83ed5d3658e7e15a7f750a04a6cc874 100644 --- a/include/class.ticket.php +++ b/include/class.ticket.php @@ -192,7 +192,7 @@ EOF; } } -RolePermission::register(/* @trans */ 'Tickets', TicketModel::getPermissions()); +RolePermission::register(/* @trans */ 'Tickets', TicketModel::getPermissions(), true); class TicketCData extends VerySimpleModel { static $meta = array( diff --git a/include/staff/templates/thread-entry-edit.tmpl.php b/include/staff/templates/thread-entry-edit.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..e1ed1f81c9e062f8c0a45bc4a1bc6c925759681f --- /dev/null +++ b/include/staff/templates/thread-entry-edit.tmpl.php @@ -0,0 +1,82 @@ +<h3><?php echo __('Edit Thread Entry'); ?></h3> +<b><a class="close" href="#"><i class="icon-remove-circle"></i></a></b> +<hr/> + +<form method="post" action="<?php echo $this->getAjaxUrl(true); ?>"> + +<input type="text" style="width:100%;font-size:14px" placeholder="<?php + echo __('Title'); ?>" name="title" value="<?php + echo Format::htmlchars($this->entry->title); ?>"/> +<hr style="height:0"/> +<textarea style="display: block; width: 100%; height: auto; min-height: 150px;" +<?php if ($poster && $this->entry->type == 'R') { + $signature_type = $poster->getDefaultSignatureType(); + $signature = ''; + if (($T = $this->entry->getThread()->getObject()) instanceof Ticket) + $dept = $T->getDept(); + switch ($poster->getDefaultSignatureType()) { + case 'dept': + if ($dept && $dept->canAppendSignature()) + $signature = $dept->getSignature(); + break; + case 'mine': + $signature = $poster->getSignature(); + $signature_type = 'theirs'; + break; + } ?> + data-dept-id="<?php echo $dept->getId(); ?>" + data-poster-id="<?php echo $this->entry->staff_id; ?>" + data-signature-field="signature" + data-signature="<?php echo Format::htmlchars(Format::viewableImages($signature)); ?>" +<?php } ?> + name="body" + class="large <?php + if ($cfg->isHtmlThreadEnabled() && $this->entry->format == 'html') + echo 'richtext'; + ?>"><?php echo Format::viewableImages($this->entry->body); +?></textarea> + +<?php if ($this->entry->type == 'R') { ?> +<div style="margin:10px 0;"><strong><?php echo __('Signature'); ?>:</strong> + <label><input type="radio" name="signature" value="none" checked="checked"> <?php echo __('None');?></label> + <?php + if ($poster + && $poster->getId() != $thisstaff->getId() + && $poster->getSignature() + ) { ?> + <label><input type="radio" name="signature" value="theirs" + <?php echo ($info['signature']=='theirs')?'checked="checked"':''; ?>> <?php echo __('Their Signature');?></label> + <?php + } + if ($thisstaff->getSignature()) {?> + <label><input type="radio" name="signature" value="mine" + <?php echo ($info['signature']=='mine')?'checked="checked"':''; ?>> <?php echo __('My Signature');?></label> + <?php + } ?> + <?php + if ($dept && $dept->canAppendSignature()) { ?> + <label><input type="radio" name="signature" value="dept" + <?php echo ($info['signature']=='dept')?'checked="checked"':''; ?>> + <?php echo sprintf(__('Department Signature (%s)'), Format::htmlchars($dept->getName())); ?></label> + <?php + } ?> +</div> +<?php } # end of type == 'R' ?> + +<hr> +<div class="full-width"> + <span class="buttons pull-left"> + <input type="button" name="cancel" class="close" + value="<?php echo __('Cancel'); ?>"> + </span> + <span class="buttons pull-right"> + <button type="submit" name="commit" value="save" class="button" + ><?php echo __('Save'); ?></button> +<?php if ($this->entry->type == 'R') { ?> + <button type="submit" name="commit" value="resend" class="button" + ><?php echo __('Save and Resend'); ?></button> +<?php } ?> + </span> +</div> + +</form> diff --git a/include/staff/templates/thread-entry-resend.tmpl.php b/include/staff/templates/thread-entry-resend.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..8a3e283c6418c0cdadd26b3db34a4fbc8c59f440 --- /dev/null +++ b/include/staff/templates/thread-entry-resend.tmpl.php @@ -0,0 +1,50 @@ +<h3><?php echo __('Resend Entry'); ?></h3> +<b><a class="close" href="#"><i class="icon-remove-circle"></i></a></b> +<hr/> + +<form method="post" action="<?php echo $this->getAjaxUrl(true); ?>"> + +<div class="thread-body" style="background-color: transparent; max-height: 150px; width: 100%; overflow: scroll;"> + <?php echo $this->entry->getBody()->toHtml(); ?> +</div> + +<?php if ($this->entry->type == 'R') { ?> +<div style="margin:10px 0;"><strong><?php echo __('Signature'); ?>:</strong> + <label><input type="radio" name="signature" value="none" checked="checked"> <?php echo __('None');?></label> + <?php + if ($poster + && $poster->getId() != $thisstaff->getId() + && $poster->getSignature() + ) { ?> + <label><input type="radio" name="signature" value="theirs" + <?php echo ($info['signature']=='theirs')?'checked="checked"':''; ?>> <?php echo __('Their Signature');?></label> + <?php + } + if ($thisstaff->getSignature()) {?> + <label><input type="radio" name="signature" value="mine" + <?php echo ($info['signature']=='mine')?'checked="checked"':''; ?>> <?php echo __('My Signature');?></label> + <?php + } ?> + <?php + if ($dept && $dept->canAppendSignature()) { ?> + <label><input type="radio" name="signature" value="dept" + <?php echo ($info['signature']=='dept')?'checked="checked"':''; ?>> + <?php echo sprintf(__('Department Signature (%s)'), Format::htmlchars($dept->getName())); ?></label> + <?php + } ?> +</div> +<?php } # end of type == 'R' ?> + +<hr> +<p class="full-width"> + <span class="buttons pull-left"> + <input type="button" name="cancel" class="close" + value="<?php echo __('Cancel'); ?>"> + </span> + <span class="buttons pull-right"> + <input type="submit" name="save" + value="<?php echo __('Resend'); ?>"> + </span> +</p> + +</form> diff --git a/include/staff/templates/thread-entry-view.tmpl.php b/include/staff/templates/thread-entry-view.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..2b76d851f3611a89e452492117593ebf17203b37 --- /dev/null +++ b/include/staff/templates/thread-entry-view.tmpl.php @@ -0,0 +1,58 @@ +<h3><?php echo __('Original Thread Entry'); ?></h3> +<b><a class="close" href="#"><i class="icon-remove-circle"></i></a></b> +<hr/> + +<div id="history" class="accordian"> + +<?php +$E = $entry; +do { ?> +<dt> + <a href="#"><i class="icon-copy"></i> + <strong><?php if ($E->title) + echo Format::htmlchars($E->title).' — '; ?></strong> + <em><?php if (strpos($E->updated, '0000-') === false) + echo sprintf(__('Edited on %s'), Format::datetime($E->updated)); + else + echo __('Original'); ?></em> + </a> +</dt> +<dd class="hidden" style="background-color:transparent"> + <div class="thread-body" style="background-color:transparent"> + <?php echo $E->getBody()->toHtml(); ?> + </div> +</dd> +<?php +} +while (($E = $E->getParent()) && $E->type == $entry->type); +?> + +</div> + +<hr> +<p class="full-width"> + <span class="buttons pull-right"> + <input type="button" name="cancel" class="close" + value="<?php echo __('Close'); ?>"> + </span> +</p> + +</form> + +<script type="text/javascript"> +$(function() { + var I = setInterval(function() { + var A = $('#history.accordian'); + if (!A.length) return; + clearInterval(I); + + var allPanels = $('dd', A).hide().removeClass('hidden'); + $('dt > a', A).click(function() { + $('dt', A).removeClass('active'); + allPanels.slideUp(); + $(this).parent().addClass('active').next().slideDown(); + return false; + }); + allPanels.last().show(); + }, 100); +}); diff --git a/include/staff/ticket-view.inc.php b/include/staff/ticket-view.inc.php index e2cc27c5f3441b18d42b9c6b227b6ae0583158dc..bdc09745da094d2daef7cb944830c7a4a267e9e7 100644 --- a/include/staff/ticket-view.inc.php +++ b/include/staff/ticket-view.inc.php @@ -423,9 +423,13 @@ $tcount = $ticket->getThreadEntries($types)->count(); <?php foreach ($actions as $group => $list) { foreach ($list as $id => $action) { ?> <li> - <a class="no-pjax" href="#" onclick="javascript: - <?php echo str_replace('"', '\\"', $action->getJsStub()); ?>; return false;"> - <i class="<?php echo $action->getIcon(); ?>"></i> <?php + <a class="no-pjax <?php + if (!$action->isEnabled()) + echo 'disabled'; + ?>" href="#" onclick="javascript: + if ($(this).hasClass('disabled')) return false; + <?php echo str_replace('"', '\\"', $action->getJsStub()); ?>; return false;"> + <i class="icon-fixed-width <?php echo $action->getIcon(); ?>"></i> <?php echo $action->getName(); ?></a></li> <?php } @@ -434,7 +438,13 @@ $tcount = $ticket->getThreadEntries($types)->count(); </div> <?php } ?> <span style="vertical-align:middle"> - <span style="vertical-align:middle;" class="textra"></span> + <span style="vertical-align:middle;" class="textra"> + <?php if ($entry->flags & ThreadEntry::FLAG_EDITED) { ?> + <span class="label label-bare" title="<?php + echo sprintf(__('Edited on %s by %s'), Format::datetime($entry->updated), 'You'); + ?>"><?php echo __('Edited'); ?></span> + <?php } ?> + </span> <span style="vertical-align:middle;" class="tmeta faded title"><?php echo Format::htmlchars($entry->getName()); ?></span> diff --git a/include/staff/tickets.inc.php b/include/staff/tickets.inc.php index bdab6cd1db6cdbe7d3d7aad223c878c87504b520..8f6a86ce857528594c9e6ee2c607d2ecdcb5485c 100644 --- a/include/staff/tickets.inc.php +++ b/include/staff/tickets.inc.php @@ -150,12 +150,11 @@ if (!$view_all_tickets) { if ($teams = array_filter($thisstaff->getTeams())) $assigned->add(array('team_id__in' => $teams)); - $visibility = array( - new Q(array('status__state'=>'open', $assigned)) - ); + $visibility = Q::any(array('status__state'=>'open', $assigned)); + // -- Routed to a department of mine if (!$thisstaff->showAssignedOnly() && ($depts=$thisstaff->getDepts())) - $visibility[] = new Q(array('dept_id__in' => $depts)); + $visibility->add(array('dept_id__in' => $depts)); $tickets->filter(Q::any($visibility)); } @@ -266,9 +265,19 @@ $tickets->values('lock__staff_id', 'staff_id', 'isoverdue', 'team_id', 'ticket_i // Add in annotations $tickets->annotate(array( - 'collab_count' => SqlAggregate::COUNT('thread__collaborators'), - 'attachment_count' => SqlAggregate::COUNT('thread__entries__attachments'), - 'thread_count' => SqlAggregate::COUNT('thread__entries'), + 'collab_count' => SqlAggregate::COUNT('thread__collaborators', true), + 'attachment_count' => SqlAggregate::COUNT(SqlCase::N() + ->when(new SqlField('thread__entries__attachments__inline'), null) + ->otherwise(new SqlField('thread__entries__attachments')), + true + ), + 'thread_count' => SqlAggregate::COUNT(SqlCase::N() + ->when( + new Q(array('thread__entries__flags__hasbit'=>ThreadEntry::FLAG_HIDDEN)), + null) + ->otherwise(new SqlField('thread__entries__id')), + true + ), )); // Save the query to the session for exporting diff --git a/js/redactor-osticket.js b/js/redactor-osticket.js index bd32edda61afbc875b2aa08d3fef296f6912db39..203c398944384fa5fb620e6cdf5652aa0d8d950e 100644 --- a/js/redactor-osticket.js +++ b/js/redactor-osticket.js @@ -156,10 +156,10 @@ RedactorPlugins.signature = function() { else this.$signatureBox.hide(); $('input[name='+$el.data('signatureField')+']', $el.closest('form')) - .on('change', false, false, $.proxy(this.updateSignature, this)); + .on('change', false, false, $.proxy(this.signature.updateSignature, this)); if ($el.data('deptField')) $(':input[name='+$el.data('deptField')+']', $el.closest('form')) - .on('change', false, false, $.proxy(this.updateSignature, this)); + .on('change', false, false, $.proxy(this.signature.updateSignature, this)); // Expand on hover var outer = this.$signatureBox, inner = $('.inner', this.$signatureBox).get(0), @@ -200,6 +200,9 @@ RedactorPlugins.signature = function() { else return inner.empty().parent().hide(); } + else if (selected == 'theirs' && $el.data('posterId')) { + url += 'agent/' + $el.data('posterId'); + } else if (type == 'none') return inner.empty().parent().hide(); else @@ -251,7 +254,8 @@ $(function() { 'file', 'table', 'link', '|', 'alignment', '|', 'horizontalrule'], 'buttonSource': !el.hasClass('no-bar'), - 'autoresize': !el.hasClass('no-bar'), + 'autoresize': !el.hasClass('no-bar') && !el.closest('.dialog').length, + 'maxHeight': el.closest('.dialog').length ? selectedSize : false, 'minHeight': selectedSize, 'focus': false, 'plugins': el.hasClass('no-bar') diff --git a/scp/css/dropdown.css b/scp/css/dropdown.css index 6105ea27fc4098cc3ba0fce3ae3467af0e8e7afb..236deb3664c1c7f424e901130e9a71c41e7fd2c4 100644 --- a/scp/css/dropdown.css +++ b/scp/css/dropdown.css @@ -42,6 +42,11 @@ color: #FFF !important; cursor: pointer; } +.action-dropdown ul li > a.disabled { + pointer-events: none; + color: #999; + color: rgba(85,85,85,0.5); +} .action-dropdown hr { height: 1px; border: none; diff --git a/scp/css/scp.css b/scp/css/scp.css index 64735d3de27b7959e24fe40d1179aa2aff0764e1..7817e76560c9610788831f445bd6caa6f4c743e5 100644 --- a/scp/css/scp.css +++ b/scp/css/scp.css @@ -1947,7 +1947,7 @@ tr.disabled th { .label { font-size: 11px; - padding: 1px 4px 2px; + padding: 1px 4px; -webkit-border-radius: 3px; -moz-border-radius: 3px; border-radius: 3px; @@ -1959,6 +1959,13 @@ tr.disabled th { text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); background-color: #999999; } +.label-bare { + background-color: transparent; + background-color: rgba(0,0,0,0); + border: 1px solid #999999; + color: #999999; + text-shadow: none; +} .label-info { background-color: #3a87ad; } diff --git a/scp/js/scp.js b/scp/js/scp.js index 8f99d9545c636d7b4e802521632f33c162ca1b19..aed3618a2be9c6bb4a99301f5e38303cafca4821 100644 --- a/scp/js/scp.js +++ b/scp/js/scp.js @@ -562,16 +562,25 @@ $.dialog = function (url, codes, cb, options) { queue: false, complete: function() { if (options.onshow) options.onshow(); } }); + var submit_button = null; $(document).off('.dialog'); + $(document).on('click.dialog', + '#popup input[type=submit], #popup button[type=submit]', + function(e) { submit_button = $(this); }); $(document).on('submit.dialog', '.dialog#popup form', function(e) { e.preventDefault(); - var $form = $(this); + var $form = $(this), + data = $form.serialize(); + if (submit_button) { + data += '&' + escape(submit_button.attr('name')) + '=' + + escape(submit_button.attr('value')); + } $('div#popup-loading', $popup).show() .find('h1').css({'margin-top':function() { return $popup.height()/3-$(this).height()/3}}); $.ajax({ type: $form.attr('method'), url: 'ajax.php/'+$form.attr('action').substr(1), - data: $form.serialize(), + data: data, cache: false, success: function(resp, status, xhr) { if (xhr && xhr.status && codes diff --git a/scp/roles.php b/scp/roles.php index 824d13b14e285a4ae22d22283a9e988f36eaf0bf..749dee96fad1f00cf7d40535a3a242b7b4b93356 100644 --- a/scp/roles.php +++ b/scp/roles.php @@ -21,6 +21,7 @@ include_once INCLUDE_DIR . 'class.canned.php'; include_once INCLUDE_DIR . 'class.faq.php'; include_once INCLUDE_DIR . 'class.email.php'; include_once INCLUDE_DIR . 'class.report.php'; +include_once INCLUDE_DIR . 'class.thread.php'; $errors = array(); $role=null;