['days' => 3],
'Weekly' => ['days' => 7],
'Biweekly' => ['days' => 14],
'Monthly' => ['months' => 1],
'Quarterly' => ['months' => 3],
'Biannually' => ['months' => 6],
'Annually' => ['months' => 12],
];
/** May this agent do this with tickets? 1 is to see them, 2 to work them, 3 to delete them. */
function ticketCan($level) {
return lookupUserPermission('module_support') >= $level;
}
/** One ticket by id, with its client, contact, status and agent - raw, or null. */
function ticketRow($ticket_id) {
global $mysqli;
$ticket_id = intval($ticket_id);
if (!$ticket_id) {
return null;
}
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT tickets.*, client_name, contact_name, contact_email,
ticket_status_name, ticket_status_color, user_name, user_avatar
FROM tickets
LEFT JOIN clients ON client_id = ticket_client_id
LEFT JOIN contacts ON contact_id = ticket_contact_id
LEFT JOIN ticket_statuses ON ticket_status_id = ticket_status
LEFT JOIN users ON user_id = ticket_assigned_to
WHERE ticket_id = $ticket_id LIMIT 1"));
return $row ?: null;
}
/**
* For post handlers: the ticket a request names, after the module and client
* checks (a ticket with no client is internal work any agent may reach) - or
* back with an error if it is gone. Sets the global $client_id.
*/
function ticketForRequest($ticket_id, $level) {
global $client_id;
enforceUserPermission('module_support', $level);
$row = ticketRow($ticket_id);
if (!$row) {
flashAlert('That ticket no longer exists.', 'error');
redirect();
}
$client_id = intval($row['ticket_client_id']);
if ($client_id) {
enforceClientAccess();
}
return $row;
}
/**
* For modals: the ticket a modal is opened for, after the module and client
* checks - or the modal's error, and nothing more. $open_only refuses a
* closed ticket. Sets the global $client_id.
*/
function ticketForModal($ticket_id, $level = 2, $open_only = false) {
global $client_id;
enforceUserPermission('module_support', $level);
$row = ticketRow($ticket_id);
if (!$row) {
echo json_encode(['error' => 'That ticket no longer exists.']);
exit;
}
$client_id = intval($row['ticket_client_id']);
if ($client_id) {
enforceClientAccess();
}
if ($open_only && !empty($row['ticket_closed_at'])) {
echo json_encode(['error' => 'Ticket ' . ticketReference($row) . ' is closed.']);
exit;
}
return $row;
}
/** For post handlers: the tickets a bulk action names (ticket_ids[]) that this agent may act on - [id => row]. */
function ticketsForBulk($level) {
global $client_id;
enforceUserPermission('module_support', $level);
$rows = [];
foreach (array_unique(array_map('intval', (array) ($_POST['ticket_ids'] ?? []))) as $ticket_id) {
$row = ticketRow($ticket_id);
if (!$row) {
continue;
}
$client_id = intval($row['ticket_client_id']);
if ($client_id) {
enforceClientAccess();
}
$rows[$ticket_id] = $row;
}
return $rows;
}
/** Its reference - TCK-1234. */
function ticketReference($row) {
return ($row['ticket_prefix'] ?? '') . intval($row['ticket_number'] ?? 0);
}
/** Is it still being worked - neither resolved nor closed? */
function ticketIsOpen($row) {
return empty($row['ticket_resolved_at']) && empty($row['ticket_closed_at']);
}
/** Where the ticket page is - inside its client when it has one. */
function ticketUrl($ticket_id, $client_id = 0) {
$client_id = intval($client_id);
return 'ticket.php?' . ($client_id ? "client_id=$client_id&" : '') . 'ticket_id=' . intval($ticket_id);
}
/** A ticket wherever a page mentions one: its reference, then its subject, both opening the ticket. 'subject' => false for the reference alone. */
function ticketLink($row, $options = []) {
$url = escapeHtml(ticketUrl($row['ticket_id'], $row['ticket_client_id'] ?? 0));
$reference = escapeHtml(ticketReference($row));
$html = '' . $reference . '';
if (($options['subject'] ?? true) && isset($row['ticket_subject'])) {
$html .= ' ' . escapeHtml($row['ticket_subject']) . '';
}
return $html;
}
/**
* A record a ticket of this client may link to - its id when it is the
* client's own, else 0. $kind is contact, asset, location, vendor or
* project. A project may also be one with no client (internal work); a
* ticket with no client may only link to our own vendors and projects.
*/
function ticketClientOwnedId($kind, $id, $client_id) {
global $mysqli;
$id = intval($id);
$client_id = intval($client_id);
if (!$id) {
return 0;
}
if ($kind === 'project') {
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT project_id FROM projects WHERE project_id = $id AND project_client_id IN (0, $client_id) LIMIT 1"));
return $row ? $id : 0;
}
if (!$client_id && $kind !== 'vendor') {
return 0;
}
return assetClientOwnedId($kind, $id, $client_id);
}
/** The client's own assets out of a list of ids, without the primary one - for "other assets". */
function ticketClientOwnedAssetIds($ids, $client_id, $primary_asset_id = 0) {
global $mysqli;
$ids = array_values(array_diff(array_unique(array_filter(array_map('intval', (array) $ids))), [intval($primary_asset_id)]));
$client_id = intval($client_id);
if (!$ids || !$client_id) {
return [];
}
$owned = [];
$sql = mysqli_query($mysqli, "SELECT asset_id FROM assets WHERE asset_client_id = $client_id AND asset_id IN (" . implode(',', $ids) . ")");
while ($row = mysqli_fetch_row($sql)) {
$owned[] = intval($row[0]);
}
return $owned;
}
/** Replace a ticket's other assets with these (already checked) ids. */
function ticketSetOtherAssets($ticket_id, array $asset_ids) {
global $mysqli;
$ticket_id = intval($ticket_id);
mysqli_query($mysqli, "DELETE FROM ticket_assets WHERE ticket_id = $ticket_id");
foreach ($asset_ids as $asset_id) {
mysqli_query($mysqli, "INSERT IGNORE INTO ticket_assets SET ticket_id = $ticket_id, asset_id = " . intval($asset_id));
}
}
/** An active agent's id, else 0 (unassigned). */
function ticketAgentId($user_id) {
global $mysqli;
$user_id = intval($user_id);
if (!$user_id) {
return 0;
}
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT user_id FROM users WHERE user_id = $user_id AND user_type = 1 AND user_status = 1 AND user_archived_at IS NULL LIMIT 1"));
return $row ? $user_id : 0;
}
/** A ticket category's id, else 0 (not categorised). */
function ticketCategoryId($category_id) {
global $mysqli;
$category_id = intval($category_id);
if (!$category_id) {
return 0;
}
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT category_id FROM categories WHERE category_id = $category_id AND category_type = 'Ticket' LIMIT 1"));
return $row ? $category_id : 0;
}
/** A priority as posted, or Medium when it isn't one. */
function ticketPriority($priority) {
return in_array($priority, TICKET_PRIORITIES, true) ? $priority : 'Medium';
}
/** A due date posted from a datetime-local field (with or without seconds) as SQL - quoted, or NULL. */
function ticketDueSql($input) {
$input = trim((string) $input);
if ($input === '') {
return 'NULL';
}
foreach (['Y-m-d\TH:i', 'Y-m-d\TH:i:s', 'Y-m-d H:i:s', 'Y-m-d H:i'] as $format) {
$date = DateTime::createFromFormat("!$format", $input);
if ($date && $date->format($format) === $input) {
return "'" . $date->format('Y-m-d H:i:s') . "'";
}
}
return 'NULL';
}
/** The watchers posted - valid email addresses only, each once, lower-cased. */
function ticketWatcherEmails($input) {
$list = is_array($input) ? $input : preg_split('/[\s,;]+/', (string) $input);
$emails = [];
foreach ($list as $email) {
$email = strtolower(trim((string) $email));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$emails[$email] = true;
}
}
return array_keys($emails);
}
/** Add watchers a ticket doesn't have already - the ones added. */
function ticketAddWatchers($ticket_id, array $emails) {
global $mysqli;
$ticket_id = intval($ticket_id);
$added = [];
foreach ($emails as $email) {
$email_sql = escapeSql($email);
if (mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM ticket_watchers WHERE watcher_ticket_id = $ticket_id AND watcher_email = '$email_sql' LIMIT 1"))) {
continue;
}
mysqli_query($mysqli, "INSERT INTO ticket_watchers SET watcher_email = '$email_sql', watcher_ticket_id = $ticket_id");
$added[] = $email;
}
return $added;
}
/**
* Delete a ticket and everything that is only its: replies, views, watchers,
* attachments (and their files), tasks and their approvals, history and SLA
* history. Its other-asset links go with it (ON DELETE CASCADE).
*/
function ticketDeleteForGood($ticket_id) {
global $mysqli;
$ticket_id = intval($ticket_id);
if (!$ticket_id) {
return;
}
mysqli_query($mysqli, "DELETE FROM task_approvals WHERE approval_task_id IN (SELECT task_id FROM tasks WHERE task_ticket_id = $ticket_id)");
mysqli_query($mysqli, "DELETE FROM tasks WHERE task_ticket_id = $ticket_id");
mysqli_query($mysqli, "DELETE FROM ticket_replies WHERE ticket_reply_ticket_id = $ticket_id");
mysqli_query($mysqli, "DELETE FROM ticket_views WHERE view_ticket_id = $ticket_id");
mysqli_query($mysqli, "DELETE FROM ticket_watchers WHERE watcher_ticket_id = $ticket_id");
mysqli_query($mysqli, "DELETE FROM ticket_attachments WHERE ticket_attachment_ticket_id = $ticket_id");
mysqli_query($mysqli, "DELETE FROM ticket_history WHERE ticket_history_ticket_id = $ticket_id");
mysqli_query($mysqli, "DELETE FROM sla_history WHERE sla_history_ticket_id = $ticket_id");
// The prepaid hours it drew go back to their contracts
if (function_exists('contractHoursReady') && contractHoursReady()) {
mysqli_query($mysqli, "DELETE FROM contract_block_uses WHERE contract_block_use_ticket_id = $ticket_id");
}
mysqli_query($mysqli, "DELETE FROM tickets WHERE ticket_id = $ticket_id");
// The driving for it still happened - the trips stay, without the ticket
mysqli_query($mysqli, "UPDATE trips SET trip_ticket_id = 0 WHERE trip_ticket_id = $ticket_id");
removeDirectory(__DIR__ . "/../uploads/tickets/$ticket_id");
}
/* ------------------------------------------------------------ Emails about a ticket */
/** Who ticket emails come from - the ticket mailbox (Admin > Mail). */
function ticketEmailFrom() {
return emailFrom('ticket');
}
/** Where a contact or watcher opens a ticket - its guest link, no sign-in needed, or the portal when it has none. */
function ticketEmailUrl(array $row) {
global $config_base_url;
$ticket_id = intval($row['ticket_id']);
return !empty($row['ticket_url_key'])
? "https://$config_base_url/guest/guest_view_ticket.php?ticket_id=$ticket_id&url_key=" . rawurlencode((string) $row['ticket_url_key'])
: "https://$config_base_url/client/ticket.php?id=$ticket_id";
}
/*
* A ticket opened by its guest link - ticketRow() when $url_key is the
* ticket's key exactly, else null. The key is all the guest pages have to go
* on, so it is compared here, whole and case for case: MySQL's = would let
* another case or trailing spaces match.
*/
function ticketByGuestKey($ticket_id, $url_key) {
$row = ticketRow($ticket_id);
$key = (string) ($row['ticket_url_key'] ?? '');
return $row && $key !== '' && is_string($url_key) && hash_equals($key, $url_key) ? $row : null;
}
/** The guest ticket page's address, from a page in guest/. */
function ticketGuestPath(array $row) {
return 'guest_view_ticket.php?ticket_id=' . intval($row['ticket_id']) . '&url_key=' . rawurlencode((string) $row['ticket_url_key']);
}
/** The foot's contact line on a ticket email - the company's phone and the support address. */
function ticketEmailContactLine() {
$company = billingCompany();
return implode(' ยท ', array_filter([$company['phone_display'] ?? '', ticketEmailFrom()['email'], $company['company_website'] ?? '']));
}
/*
* What a new ticket's SLA promises the client - ['summary' => rows to add,
* 'callout' => a paragraph]: when they will hear back, and for an Urgent or
* High one, to phone instead of waiting. Nothing when no SLA with a response
* target applies. Call it after applyTicketSla(), which sets the due time.
*/
function ticketEmailSla($ticket_id) {
global $mysqli;
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_priority, ticket_response_due_at, sla_response_minutes
FROM tickets LEFT JOIN slas ON ticket_sla_id = sla_id WHERE ticket_id = " . intval($ticket_id) . " LIMIT 1"));
if (!$row || empty($row['ticket_response_due_at']) || intval($row['sla_response_minutes']) <= 0) {
return ['summary' => [], 'callout' => ''];
}
$phone = billingCompany()['phone_display'] ?? '';
$callout = '';
if ($phone !== '' && $row['ticket_priority'] === 'Urgent') {
$callout = "This ticket is marked Urgent. If it is stopping work right now, call us on $phone rather than waiting for a reply.";
} elseif ($phone !== '' && $row['ticket_priority'] === 'High') {
$callout = "This ticket is marked High priority. If it is holding up your business, calling us on $phone gets you the fastest answer.";
}
return [
'summary' => ['We will respond' => 'within ' . formatSlaMinutes(intval($row['sla_response_minutes'])) . ', by ' . date('D j M, g:i A', strtotime($row['ticket_response_due_at']))],
'callout' => $callout,
];
}
/*
* The conversation on a ticket, for the end of an email about it
* (emailHtml()'s history): its public replies newest first, and at the end
* the request it was opened with - each a short plain-text excerpt
* (ticketEmailExcerpt()), so a pasted screenshot or a long signature can't
* swell the email. Never an internal note, nor the lines the portal writes
* when a contact resolves, reopens or closes. $opts:
* label the heading - Earlier on this ticket
* skip the HTML of the reply the email itself shows - it, and anything newer, is left out
* replies false for the request alone
* request false leaves the request out, when the email shows it already
* limit how many replies at most (4) - the rest are counted, before the request
* [] when there is nothing earlier to show.
*/
function ticketEmailHistory(array $row, array $opts = []) {
global $mysqli;
$ticket_id = intval($row['ticket_id']);
$limit = max(0, intval($opts['limit'] ?? 4));
$company_name = (string) (billingCompany()['company_name'] ?? '');
$items = [];
$left_out = 0;
if ($opts['replies'] ?? true) {
$sql = mysqli_query($mysqli, "SELECT ticket_reply, ticket_reply_type, ticket_reply_created_at, contact_name, user_name FROM ticket_replies
LEFT JOIN users ON ticket_reply_type != 'Client' AND user_id = ticket_reply_by
LEFT JOIN contacts ON ticket_reply_type = 'Client' AND contact_id = ticket_reply_by
WHERE ticket_reply_ticket_id = $ticket_id AND ticket_reply_archived_at IS NULL AND ticket_reply_type IN ('Public', 'Client')
ORDER BY ticket_reply_id DESC");
$replies = mysqli_fetch_all($sql, MYSQLI_ASSOC);
// Only what came before the reply the email shows - that one is the newest, but for a preview's sample
$skip = (string) ($opts['skip'] ?? '');
if ($skip !== '') {
foreach ($replies as $i => $reply) {
if ((string) $reply['ticket_reply'] === $skip) {
$replies = array_slice($replies, $i + 1);
break;
}
}
}
foreach ($replies as $reply) {
$text = ticketEmailExcerpt($reply['ticket_reply']);
$client = $reply['ticket_reply_type'] === 'Client';
if ($text === '' || ($client && preg_match('/^Ticket (resolved|reopened|closed) by .{1,200}\.$/', $text))) {
continue;
}
if (count($items) >= $limit) {
$left_out++;
continue;
}
$items[] = [
'by' => $client ? ((string) $reply['contact_name'] ?: 'Client') : ((string) $reply['user_name'] ?: $company_name),
'org' => $client ? '' : $company_name,
'when' => date('D j M, g:i A', strtotime($reply['ticket_reply_created_at'])),
'text' => $text,
'client' => $client,
];
}
}
if (!($opts['request'] ?? true) && $left_out) {
$items[] = ['gap' => $left_out];
} elseif ($opts['request'] ?? true) {
$text = ticketEmailExcerpt($row['ticket_details'] ?? '');
if ($text !== '') {
if ($left_out) {
$items[] = ['gap' => $left_out];
}
$asked_by = trim((string) ($row['contact_name'] ?? ''));
$items[] = [
'by' => $asked_by !== '' ? $asked_by : 'The request',
'action' => $asked_by !== '' ? 'the request' : '',
'when' => date('D j M, g:i A', strtotime($row['ticket_created_at'])),
'text' => $text,
'client' => true,
];
}
}
return $items ? ['label' => (string) ($opts['label'] ?? 'Earlier on this ticket'), 'items' => $items] : [];
}
/** A ticket message as a short plain-text excerpt - without the "Email from: ... :-" line the email parser puts on what it saves. */
function ticketEmailExcerpt($html, $limit = 700) {
$html = preg_replace('#^\s*\s*Email from:.*?(\s*
)*#is', '', (string) $html);
return emailPlainText($html, $limit);
}
/*
* What an email about a ticket says - emailHtml()'s fields (functions/email.php),
* all but who it is to and why they get it, which ticketMailSend() adds for
* each. $row is ticketRow(). $kind and its $opts:
* created it was opened - how: agent (the default), email (the reply to an emailed one) or scheduled
* reply an agent's reply - message (its HTML), by (who wrote it)
* resolved marked resolved, pending closure - message (the agent's note, HTML, or ''), by
* closed closed - asking how we did
* watcher they were made a watcher - by (who added them)
* scheduled the work was booked - when (words), where (the site's address), or cancelled: true
*/
function ticketEmailContent(array $row, $kind, array $opts = []) {
global $config_ticket_autoclose_hours;
$reference = ticketReference($row);
$subject = (string) $row['ticket_subject'];
$url = ticketEmailUrl($row);
$company_name = (string) (billingCompany()['company_name'] ?? '');
$by = trim((string) ($opts['by'] ?? '')) !== '' ? trim((string) $opts['by']) : $company_name;
$summary = ['Ticket' => $reference, 'Subject' => $subject, 'Status' => (string) ($row['ticket_status_name'] ?? '')];
$email = [
'marker' => true,
'preview' => "$reference - $subject",
'tone' => 'primary',
'contact' => ticketEmailContactLine(),
'button' => ['label' => 'View the ticket', 'url' => $url],
];
if ($kind === 'created') {
$how = (string) ($opts['how'] ?? 'agent');
$sla = ticketEmailSla($row['ticket_id']);
$email['kind'] = 'New ticket';
if ($how === 'email') {
$email['title'] = 'We have your email';
$email['intro'] = ["Thank you - it is ticket $reference now, and we will reply to you here. To add anything, just reply to this email."];
// Never their own words back: the sender of an email can be forged, and an echo would carry whatever it said to them
} else {
$email['title'] = $how === 'scheduled' ? 'A scheduled ticket was opened for you' : 'We have opened a ticket for you';
$email['intro'] = [($how === 'scheduled' ? "Ticket $reference was opened for you, as scheduled." : "We have opened ticket $reference for you.")
. " We will keep you posted here - to add anything, just reply to this email."];
$email['message'] = ['html' => (string) $row['ticket_details'], 'label' => 'Details'];
}
$email['summary'] = $summary + ['Priority' => (string) $row['ticket_priority']] + $sla['summary'];
$email['callout'] = $sla['callout'];
$email['button']['label'] = 'View your ticket';
} elseif ($kind === 'reply') {
$email['kind'] = 'Ticket update';
$email['title'] = $subject;
$email['intro'] = ["There is a new reply on your ticket $reference."];
$email['message'] = ['html' => (string) ($opts['message'] ?? ''), 'label' => "$by replied"];
$email['summary'] = $summary;
$email['after'] = ['To answer, just reply to this email.'];
$email['history'] = ticketEmailHistory($row, ['skip' => (string) ($opts['message'] ?? '')]);
} elseif ($kind === 'resolved') {
$hours = intval($config_ticket_autoclose_hours);
$closes = $hours > 0 ? ' - it closes by itself after ' . ($hours % 24 === 0 ? ($hours / 24) . ' day' . ($hours === 24 ? '' : 's') : "$hours hours") : '';
$email['kind'] = 'Resolved';
$email['tone'] = 'success';
$email['title'] = 'Your ticket is resolved';
$email['intro'] = ["We have marked ticket $reference resolved.", "If that sorts it, there is nothing more to do$closes. If not, reply to this email or reopen it and tell us what is still wrong."];
$email['message'] = ['html' => (string) ($opts['message'] ?? ''), 'label' => "$by wrote"];
$email['summary'] = $summary;
$email['button']['label'] = 'View or reopen it';
// Resolved from the button or the board there is no note - the conversation says what was done
$email['history'] = ticketEmailHistory($row, ['skip' => (string) ($opts['message'] ?? ''),
'label' => emailPlainText($opts['message'] ?? '') !== '' ? 'Earlier on this ticket' : 'The conversation']);
} elseif ($kind === 'closed') {
$email['marker'] = false;
$email['kind'] = 'Closed';
$email['tone'] = 'secondary';
$email['title'] = 'Your ticket is closed';
$email['intro'] = ["Ticket $reference is closed. We hope it was sorted the way you wanted."];
$email['summary'] = $summary;
$email['button']['label'] = 'Tell us how we did';
$email['after'] = ['Need more help with this? Raise a new ticket - replies to this email do not reach us.'];
$email['history'] = ticketEmailHistory($row, ['limit' => 3, 'label' => 'The conversation']);
} elseif ($kind === 'scheduled') {
$cancelled = !empty($opts['cancelled']);
$email['kind'] = $cancelled ? 'Visit cancelled' : 'Work booked';
$email['tone'] = $cancelled ? 'secondary' : 'primary';
$email['title'] = $cancelled ? 'The booked work is cancelled' : 'Your ticket is booked in';
$email['intro'] = [$cancelled
? "The work booked on ticket $reference is cancelled - the calendar file with this email takes it out of your calendar. We will be in touch to book it again."
: "We have booked the work on ticket $reference for " . $opts['when'] . ". The calendar file with this email adds it to your calendar."];
$email['summary'] = $summary + ($cancelled ? [] : ['When' => (string) $opts['when'], 'Where' => !empty($row['ticket_onsite']) ? (trim((string) ($opts['where'] ?? '')) !== '' ? (string) $opts['where'] : 'Onsite') : 'Remote']);
$email['history'] = $cancelled ? [] : ticketEmailHistory($row, ['limit' => 2, 'label' => 'What it is about']);
} elseif ($kind === 'watcher') {
$email['kind'] = 'Watching';
$email['title'] = 'You were added to a ticket';
$email['intro'] = ["$by added you to ticket $reference" . (!empty($row['client_name']) ? ' for ' . $row['client_name'] : '') . ", so you will get its updates. To add anything, just reply to this email."];
$email['message'] = ['html' => (string) $row['ticket_details'], 'label' => 'Details'];
$email['summary'] = $summary;
$email['history'] = ticketEmailHistory($row, ['request' => false, 'label' => 'Replies so far']);
}
return ticketEmailPreview($email);
}
/*
* The inbox's preview line, beside the subject that already gives the number
* and title - the start of the message the email carries, or else its first
* line ("We have marked ticket TCK-12 resolved.").
*/
function ticketEmailPreview(array $email) {
$said = emailPlainText($email['message']['html'] ?? '', 140);
if ($said === '') {
$said = (string) (((array) ($email['intro'] ?? []))[0] ?? '');
}
if (trim($said) !== '') {
$email['preview'] = preg_replace('/\s+/', ' ', trim($said));
}
return $email;
}
/*
* Queue an email about a ticket (ticketEmailContent()) to its contact, when
* $to_contact and the address is good, and to every watcher, each told why
* they get it. $subject is raw. Nothing is queued without a mail provider.
* How many were queued. $opts:
* attachments files to send with it ([['path', 'name'], ...])
* to_contact false leaves the contact out
* to_watchers false is the contact alone - no watchers, no colleagues
* to [['email', 'name'], ...] in place of the contact - the sender of an emailed ticket
* kind created, closed or update (the default): a contact who asked
* for replies only gets no created or closed email
* (ticketContactWantsEmail()), and the client's primary and
* technical contacts who asked for every update on their
* company's tickets get the rest, as a watcher does
* (ticketColleagueRecipients())
* ticket_reply_id the agent's reply it carries - each email is kept with it
* (emailQueue()), for ticketReplyEmailed()
*/
function ticketMailSend($ticket_id, $subject, array $email, array $opts = []) {
global $mysqli;
if (!emailReady()) {
return 0;
}
$ticket_id = intval($ticket_id);
$kind = (string) ($opts['kind'] ?? 'update');
$to_watchers = $opts['to_watchers'] ?? true;
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_prefix, ticket_number, contact_name, contact_email, contact_ticket_emails
FROM tickets LEFT JOIN contacts ON contact_id = ticket_contact_id WHERE ticket_id = $ticket_id"));
if (!$row) {
return 0;
}
$reference = ticketReference($row);
$company_name = (string) (billingCompany()['company_name'] ?? 'us');
$recipients = [];
if (isset($opts['to'])) {
foreach ((array) $opts['to'] as $to) {
$recipients[] = ['email' => $to['email'], 'name' => (string) ($to['name'] ?? ''), 'footnote' => "You get this because you emailed $company_name."];
}
} elseif (($opts['to_contact'] ?? true) && ticketContactWantsEmail($row['contact_ticket_emails'] ?? 'all', $kind)) {
$recipients[] = ['email' => (string) $row['contact_email'], 'name' => (string) $row['contact_name'], 'footnote' => "You get this as the contact on ticket $reference with $company_name."];
}
if ($to_watchers) {
$sql = mysqli_query($mysqli, "SELECT watcher_email FROM ticket_watchers WHERE watcher_ticket_id = $ticket_id");
while ($watcher = mysqli_fetch_assoc($sql)) {
$recipients[] = ['email' => trim((string) $watcher['watcher_email']), 'greet' => '', 'footnote' => "You get this because you are watching ticket $reference - ask $company_name if you would like to stop."];
}
// A new ticket reaches them by ticketMailColleaguesNew(), in an email of its own
if ($kind !== 'created') {
foreach (ticketColleagueRecipients($ticket_id, $kind) as $address => $colleague) {
$recipients[] = ['email' => $address, 'name' => $colleague['name'], 'footnote' => "You get this because you asked for every update on " . $colleague['client_name'] . "'s tickets - change that under Your account in the client portal."];
}
}
}
return count(emailQueue($recipients, $subject, $email, ['from' => ticketEmailFrom(), 'attachments' => $opts['attachments'] ?? [], 'cal_str' => $opts['cal_str'] ?? '',
'ticket_reply_id' => intval($opts['ticket_reply_id'] ?? 0)]));
}
/** Whether a ticket's contact gets an email of this $kind - no 'created' or 'closed' email for one who asked for replies only. */
function ticketContactWantsEmail($preference, $kind) {
return !((string) $preference === 'replies' && in_array($kind, ['created', 'closed'], true));
}
/**
* The client's other primary and technical contacts who asked to hear about
* their company's tickets (Your account in the portal) - for a 'created'
* email those who chose new tickets or everything, otherwise those who chose
* everything. Never the ticket's own contact, nor an archived one.
* [email => ['name', 'client_name', 'portal' => bool]].
*/
function ticketColleagueRecipients($ticket_id, $kind) {
global $mysqli;
$ticket = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_client_id, ticket_contact_id, client_name FROM tickets
JOIN clients ON client_id = ticket_client_id WHERE ticket_id = " . intval($ticket_id) . " LIMIT 1"));
if (!$ticket) {
return [];
}
$wanted = $kind === 'created' ? "'new', 'all'" : "'all'";
$recipients = [];
$sql = mysqli_query($mysqli, "SELECT contact_name, contact_email, user_id FROM contacts
LEFT JOIN users ON user_id = contact_user_id AND user_type = 2 AND user_status = 1 AND user_archived_at IS NULL AND user_auth_method IN ('local', 'azure')
WHERE contact_client_id = " . intval($ticket['ticket_client_id']) . " AND contact_id <> " . intval($ticket['ticket_contact_id']) . "
AND contact_archived_at IS NULL AND (contact_primary = 1 OR contact_technical = 1)
AND contact_company_ticket_emails IN ($wanted)");
while ($row = mysqli_fetch_assoc($sql)) {
$email = trim((string) $row['contact_email']);
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
$recipients[$email] = ['name' => (string) $row['contact_name'], 'client_name' => (string) $ticket['client_name'], 'portal' => !empty($row['user_id'])];
}
}
return $recipients;
}
/**
* Tell the colleagues who asked (ticketColleagueRecipients()) that a ticket
* was raised at their company - who raised it, what about and how urgent,
* with a link to it: in the portal for those who can sign in, else the
* ticket's guest link. Raised in the portal, by email or by an agent; client
* notifications have to be on (Admin > Ticket). How many were queued.
*/
function ticketMailColleaguesNew($ticket_id) {
global $config_ticket_client_general_notifications, $config_base_url;
if (!emailReady() || intval($config_ticket_client_general_notifications) !== 1) {
return 0;
}
$ticket = ticketRow($ticket_id);
if (!$ticket || !intval($ticket['ticket_client_id'])) {
return 0;
}
$ticket_id = intval($ticket['ticket_id']);
$reference = ticketReference($ticket);
$raised_by = trim((string) $ticket['contact_name']) !== '' ? (string) $ticket['contact_name'] : 'We';
// What was asked - not for an emailed ticket, sent on by itself: a forged sender would have us carry their words to the client
$echo = (string) $ticket['ticket_source'] !== 'Email';
$sent = 0;
foreach (ticketColleagueRecipients($ticket_id, 'created') as $email => $colleague) {
$url = $colleague['portal'] ? "https://$config_base_url/client/ticket.php?id=$ticket_id" : ticketEmailUrl($ticket);
$sent += count(emailQueue([[
'email' => $email, 'name' => $colleague['name'],
'footnote' => "You get this because you asked to hear about new tickets at " . $colleague['client_name'] . ($colleague['portal'] ? ' - change that under Your account in the client portal.' : '.'),
]], "New ticket - [$reference] - " . $ticket['ticket_subject'], [
'kind' => 'New ticket', 'tone' => 'primary', 'preview' => ($echo ? preg_replace('/\s+/', ' ', emailPlainText($ticket['ticket_details'], 140)) : '') ?: "$reference - " . $ticket['ticket_subject'],
'title' => "New ticket at " . $colleague['client_name'],
'intro' => ["$raised_by raised a ticket at " . $colleague['client_name'] . '.'],
'message' => ['html' => $echo ? (string) $ticket['ticket_details'] : '', 'label' => 'Details'],
'summary' => ['Ticket' => $reference, 'Subject' => (string) $ticket['ticket_subject'], 'Priority' => (string) $ticket['ticket_priority'], 'Raised by' => $raised_by === 'We' ? 'Us' : $raised_by],
'button' => ['label' => $colleague['portal'] ? 'View it in the portal' : 'View the ticket', 'url' => $url],
'contact' => ticketEmailContactLine(),
], ['from' => ticketEmailFrom()]));
}
return $sent;
}
/*
* The "a ticket was created for you" email - to its contact, and its watchers
* unless $to_watchers is false; or to $to (ticketMailSend()'s) in their
* place. $how agent, email or scheduled (ticketEmailContent()).
*/
function ticketMailCreated($ticket_id, $subject_prefix, $attachments = [], $to_watchers = true, $how = 'agent', ?array $to = null) {
$row = ticketRow($ticket_id);
if (!$row) {
return 0;
}
return ticketMailSend($row['ticket_id'], "$subject_prefix [" . ticketReference($row) . "] - " . $row['ticket_subject'], ticketEmailContent($row, 'created', ['how' => $how]),
['attachments' => $attachments, 'to_watchers' => $to_watchers, 'kind' => 'created'] + ($to !== null ? ['to' => $to] : []));
}
/*
* A public reply on a ticket, to its contact and watchers - $reply_html as
* saved (with the signature), $by who wrote it (raw). $resolved when the
* reply resolved it. $reply_id, the reply in ticket_replies, has who it went
* to kept with it (ticketReplyEmailed()). How many it went to.
*/
function ticketMailReply($ticket_id, $reply_html, $by, $resolved = false, $attachments = [], $reply_id = 0) {
$row = ticketRow($ticket_id);
if (!$row) {
ticketReplyEmailed($reply_id, false);
return 0;
}
$sent = ticketMailSend($row['ticket_id'], ($resolved ? 'Ticket resolved' : 'Ticket update') . " - [" . ticketReference($row) . "] - " . $row['ticket_subject'] . ($resolved ? ' | (pending closure)' : ''),
ticketEmailContent($row, $resolved ? 'resolved' : 'reply', ['message' => (string) $reply_html, 'by' => (string) $by]), ['attachments' => $attachments, 'ticket_reply_id' => intval($reply_id)]);
ticketReplyEmailed($reply_id, $sent > 0);
return $sent;
}
/** Whether the 3.4.8 update has run - a reply keeps whether it was emailed and who to, and the mail queue which reply an email carried. */
function ticketReplyEmailReady() {
global $mysqli;
static $ready = null;
if ($ready === null) {
if (defined('CURRENT_DATABASE_VERSION')) {
$version = CURRENT_DATABASE_VERSION;
} else {
$result = mysqli_query($mysqli, "SELECT config_current_database_version FROM settings WHERE company_id = 1");
$version = $result && ($row = mysqli_fetch_row($result)) ? (string) $row[0] : '0';
}
$ready = version_compare($version, '3.4.8', '>=');
}
return $ready;
}
/*
* Keep on an agent's reply whether it was emailed - and, once its emails are
* queued with its id ('ticket_reply_id'), who they went to, read back from the
* mail queue, so the conversation still says so after the queue has cleared
* them. $emailed false for a public or internal note. Whether it went to anyone.
*/
function ticketReplyEmailed($reply_id, $emailed = true) {
global $mysqli;
$reply_id = intval($reply_id);
if (!$reply_id || !ticketReplyEmailReady()) {
return false;
}
$to = [];
if ($emailed) {
$sql = mysqli_query($mysqli, "SELECT email_recipient, email_recipient_name FROM email_queue WHERE email_ticket_reply_id = $reply_id ORDER BY email_id");
while ($row = mysqli_fetch_assoc($sql)) {
$to[strtolower($row['email_recipient'])] = ['email' => (string) $row['email_recipient'],
'name' => (string) $row['email_recipient_name'] !== (string) $row['email_recipient'] ? (string) $row['email_recipient_name'] : ''];
}
}
$json = $to ? "'" . escapeSql(json_encode(array_values($to), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . "'" : 'NULL';
// ticket_reply_updated_at kept as it is - it says when the words were edited
mysqli_query($mysqli, "UPDATE ticket_replies SET ticket_reply_emailed = " . ($to ? 1 : 0) . ", ticket_reply_emailed_to = $json,
ticket_reply_updated_at = ticket_reply_updated_at WHERE ticket_reply_id = $reply_id");
return (bool) $to;
}
/*
* Who the agent replies in $rows were emailed to, and whether each email went -
* for the conversation. $rows each have ticket_reply_id, ticket_reply_emailed
* and ticket_reply_emailed_to. Reply id => ['to' => [['email', 'name', 'state'
* => mailQueueState(), or null once the queue has cleared it]], 'key' => sent,
* waiting (still to go, or being tried again), failed, cancelled, or gone (the
* queue no longer has them - kept 90 days)], for each reply emailed.
*/
function ticketReplyEmailStates(array $rows) {
global $mysqli;
$states = [];
foreach ($rows as $row) {
if (intval($row['ticket_reply_emailed'] ?? 0) !== 1) {
continue;
}
$to = [];
foreach ((array) json_decode((string) ($row['ticket_reply_emailed_to'] ?? ''), true) as $recipient) {
if (!empty($recipient['email'])) {
$to[strtolower($recipient['email'])] = ['email' => (string) $recipient['email'], 'name' => (string) ($recipient['name'] ?? ''), 'state' => null];
}
}
$states[intval($row['ticket_reply_id'])] = ['to' => $to, 'key' => 'gone'];
}
if (!$states || !ticketReplyEmailReady()) {
return array_map(function ($state) { $state['to'] = array_values($state['to']); return $state; }, $states);
}
$sql = mysqli_query($mysqli, "SELECT email_ticket_reply_id, email_recipient, email_recipient_name, email_status, email_attempts, email_queued_at, email_failed_at, email_sent_at, email_error
FROM email_queue WHERE email_ticket_reply_id IN (" . implode(',', array_keys($states)) . ") ORDER BY email_id");
while ($sql && ($email = mysqli_fetch_assoc($sql))) {
$reply_id = intval($email['email_ticket_reply_id']);
$address = strtolower((string) $email['email_recipient']);
$states[$reply_id]['to'][$address] = [
'email' => (string) $email['email_recipient'],
'name' => $states[$reply_id]['to'][$address]['name'] ?? ((string) $email['email_recipient_name'] !== (string) $email['email_recipient'] ? (string) $email['email_recipient_name'] : ''),
'state' => mailQueueState($email),
];
}
foreach ($states as $reply_id => $state) {
$keys = array_filter(array_map(function ($recipient) { return $recipient['state']['key'] ?? null; }, $state['to']));
if (!$keys) {
$key = 'gone';
} elseif (in_array('failed', $keys, true)) {
$key = 'failed';
} elseif (array_intersect(['waiting', 'sending', 'retrying'], $keys)) {
$key = 'waiting';
} elseif (!in_array('sent', $keys, true)) {
$key = 'cancelled';
} else {
$key = 'sent';
}
$states[$reply_id] = ['to' => array_values($state['to']), 'key' => $key];
}
return $states;
}
/** Tell people just made watchers of a ticket - $emails, by $by (raw). How many it went to. */
function ticketMailWatchersAdded($ticket_id, array $emails, $by) {
$row = ticketRow($ticket_id);
if (!$row || !$emails) {
return 0;
}
$company_name = (string) (billingCompany()['company_name'] ?? 'us');
return count(emailQueue(array_map(function ($watcher_email) use ($row, $company_name) {
return ['email' => $watcher_email, 'greet' => '', 'footnote' => "You get this because you are watching ticket " . ticketReference($row) . " - ask $company_name if you would like to stop."];
}, $emails), "Ticket Notification - [" . ticketReference($row) . "] - " . $row['ticket_subject'], ticketEmailContent($row, 'watcher', ['by' => (string) $by]), ['from' => ticketEmailFrom()]));
}
/* ------------------------------------------------------------ Emails about a ticket, to agents */
/** Where an agent opens a ticket - in ITFlow. */
function ticketAgentUrl(array $row) {
global $config_base_url;
return "https://$config_base_url/agent/" . ticketUrl($row['ticket_id'], $row['ticket_client_id'] ?? 0);
}
/** An agent to email - ['email', 'name'] of an active one, or null. */
function ticketMailAgent($user_id) {
global $mysqli;
$user_id = intval($user_id);
$row = $user_id ? mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_name, user_email FROM users
WHERE user_id = $user_id AND user_type = 1 AND user_status = 1 AND user_archived_at IS NULL LIMIT 1")) : null;
return $row && filter_var($row['user_email'], FILTER_VALIDATE_EMAIL) ? ['email' => $row['user_email'], 'name' => $row['user_name']] : null;
}
/*
* What an email to an agent about a ticket says - emailHtml()'s fields
* (functions/email.php), all but who it is to and why. $row is ticketRow().
* $kind and its $opts:
* new it was raised - how: portal, email, api or scheduled
* replied the client replied - how: portal or email, message (the reply, HTML), by (who)
* assigned it was assigned to them - by (who)
* mentioned an internal note mentions them - by (who), message (the note, HTML), reply_id (the note, to open)
* sla an SLA alert - track: response or resolution, breached: true once missed, due (when)
* scheduled the work was booked - when, where, or cancelled: true
* paid the invoice it waits on was paid - invoice (its number), amount, reopened (On Hold to Open)
*/
function ticketAgentEmailContent(array $row, $kind, array $opts = []) {
$reference = ticketReference($row);
$subject = (string) $row['ticket_subject'];
$client = !empty($row['client_name']) ? (string) $row['client_name'] : (intval($row['ticket_client_id'] ?? 0) ? 'A client' : 'No client');
$by = trim((string) ($opts['by'] ?? ''));
$email = [
'kind' => 'Ticket', 'tone' => 'primary', 'title' => $subject, 'preview' => "$reference - $subject",
'summary' => ['Client' => $client, 'Ticket' => $reference, 'Priority' => (string) $row['ticket_priority'], 'Status' => (string) ($row['ticket_status_name'] ?? '')],
'button' => ['label' => 'Open in ITFlow', 'url' => ticketAgentUrl($row)],
];
if ($kind === 'new') {
$how = ['portal' => ' in the client portal', 'email' => ' by email', 'api' => ' through the API', 'scheduled' => ', as scheduled'][$opts['how'] ?? ''] ?? '';
$who = ($opts['how'] ?? '') === 'scheduled' ? 'A recurring ticket was raised' : (trim((string) ($row['contact_name'] ?? '')) !== '' ? $row['contact_name'] . ' raised a ticket' : 'A ticket was raised');
$email['kind'] = 'New ticket';
$email['intro'] = [($opts['how'] ?? '') === 'scheduled' ? "$who for $client, as scheduled." : "$who$how for $client."];
$email['message'] = ['html' => (string) $row['ticket_details'], 'label' => 'Details'];
$email['summary'] = ['Client' => $client, 'Ticket' => $reference, 'Priority' => (string) $row['ticket_priority']]
+ (trim((string) ($row['contact_name'] ?? '')) !== '' ? ['Contact' => (string) $row['contact_name']] : []);
} elseif ($kind === 'paid') {
$email['kind'] = 'Paid';
$email['tone'] = 'success';
$email['intro'] = [(string) ($opts['invoice'] ?? 'Its invoice') . " for $client was paid in full - " . (string) ($opts['amount'] ?? '') . '.',
"$reference is ready to work on" . (!empty($opts['reopened']) ? ' - it is Open again, from On Hold.' : '.')];
$email['history'] = ticketEmailHistory($row, ['limit' => 2, 'label' => 'The conversation']);
} elseif ($kind === 'replied') {
$how = ['portal' => ' in the client portal', 'email' => ' by email'][$opts['how'] ?? ''] ?? '';
$email['kind'] = 'Client replied';
$email['intro'] = [($by !== '' ? $by : 'The client') . " replied to $reference$how."];
$email['message'] = ['html' => (string) ($opts['message'] ?? ''), 'label' => ($by !== '' ? $by : 'They') . ' wrote'];
$email['after'] = ['Answer it in ITFlow, so the client gets your reply.'];
$email['history'] = ticketEmailHistory($row, ['skip' => (string) ($opts['message'] ?? '')]);
} elseif ($kind === 'mentioned') {
$email['kind'] = 'Mentioned you';
$email['intro'] = [($by !== '' ? $by : 'Someone') . " mentioned you in an internal note on $reference."];
$email['message'] = ['html' => (string) ($opts['message'] ?? ''), 'label' => ($by !== '' ? $by : 'They') . ' wrote - only your team can see this'];
$email['history'] = ticketEmailHistory($row, ['limit' => 2, 'label' => 'The conversation']);
if (!empty($opts['reply_id'])) {
$email['button'] = ['label' => 'Open the note', 'url' => ticketMentionUrl($row, $opts['reply_id'], true)];
}
} elseif ($kind === 'assigned') {
$email['kind'] = 'Assigned to you';
$email['intro'] = [($by !== '' ? $by : 'Someone') . " assigned $reference to you."];
$email['message'] = ['html' => (string) $row['ticket_details'], 'label' => 'Details'];
$email['history'] = ticketEmailHistory($row, ['request' => false, 'label' => 'Replies so far']);
} elseif ($kind === 'scheduled') {
$cancelled = !empty($opts['cancelled']);
$email['kind'] = $cancelled ? 'Visit cancelled' : 'Work booked';
$email['tone'] = $cancelled ? 'secondary' : 'primary';
$email['intro'] = [$cancelled ? "The work booked on $reference is cancelled - the calendar file with this email takes it out of your calendar." : "$reference is booked for " . $opts['when'] . " - the calendar file with this email adds it to your calendar."];
if (!$cancelled) {
$email['summary'] += ['When' => (string) $opts['when'], 'Where' => !empty($row['ticket_onsite']) ? (trim((string) ($opts['where'] ?? '')) !== '' ? (string) $opts['where'] : 'Onsite') : 'Remote'];
$email['history'] = ticketEmailHistory($row, ['limit' => 2, 'label' => 'What it is about']);
}
} elseif ($kind === 'sla') {
$track = ($opts['track'] ?? 'response') === 'resolution' ? 'resolution' : 'response';
$due = !empty($opts['due']) ? date('D j M, g:i A', strtotime($opts['due'])) : '';
$breached = !empty($opts['breached']);
$email['kind'] = ucfirst($track) . ($breached ? ' SLA missed' : ' SLA at risk');
$email['tone'] = $breached ? 'danger' : 'warning';
$email['intro'] = [$breached
? "The $track due on $reference by $due was missed."
: "$reference is close to its $track SLA - it is due by $due."];
$email['summary'] += ['Assigned' => !empty($row['user_name']) ? (string) $row['user_name'] : 'Nobody', ucfirst($track) . ' due' => $due];
$email['history'] = ticketEmailHistory($row, ['limit' => 3, 'label' => 'The conversation']);
}
return ticketEmailPreview($email);
}
/** Queue an email to agents (ticketAgentEmailContent()) from the ticket mailbox - [['email', 'name', 'footnote'], ...]. How many went. */
function ticketMailAgents(array $recipients, $subject, array $email) {
return count(emailQueue($recipients, $subject, $email, ['from' => ticketEmailFrom()]));
}
/*
* Tell the new-ticket address (Admin > Ticket) a ticket was raised - $how:
* portal, email or scheduled. Nothing when it isn't set.
*/
function ticketMailNewTicketAddress($ticket_id, $how) {
global $mysqli, $config_app_name;
$address = (string) (mysqli_fetch_row(mysqli_query($mysqli, "SELECT config_ticket_new_ticket_notification_email FROM settings WHERE company_id = 1"))[0] ?? '');
$row = ticketRow($ticket_id);
if (!filter_var($address, FILTER_VALIDATE_EMAIL) || !$row) {
return 0;
}
$client = !empty($row['client_name']) ? $row['client_name'] : 'Guest';
$subject = ($config_app_name ?: 'ITFlow') . ($how === 'scheduled' ? ' - New Recurring Ticket - ' : ' - New Ticket - ') . "$client: " . $row['ticket_subject'];
return ticketMailAgents([['email' => $address, 'greet' => '', 'footnote' => 'You get this at the new-ticket address in Admin > Ticket.']],
$subject, ticketAgentEmailContent($row, 'new', ['how' => $how]));
}
/** Tell the agent a ticket is assigned to that the client replied - $how portal or email, $reply its HTML, $by who. */
function ticketMailAssigneeReplied($ticket_id, $how, $reply, $by) {
global $config_app_name;
$row = ticketRow($ticket_id);
$agent = $row ? ticketMailAgent($row['ticket_assigned_to']) : null;
if (!$agent) {
return 0;
}
return ticketMailAgents([$agent + ['footnote' => 'You get this as the agent ' . ticketReference($row) . ' is assigned to.']],
($config_app_name ?: 'ITFlow') . " - Ticket updated - [" . ticketReference($row) . "] " . $row['ticket_subject'],
ticketAgentEmailContent($row, 'replied', ['how' => $how, 'message' => (string) $reply, 'by' => (string) $by]));
}
/*
* Tell an agent tickets are theirs now - one email for any number. Not when
* they assigned them themselves. $ticket_ids, $by raw. How many went.
*/
function ticketMailAssigned($user_id, array $ticket_ids, $by) {
global $session_user_id, $config_app_name, $config_base_url;
$agent = intval($user_id) !== intval($session_user_id ?? 0) ? ticketMailAgent($user_id) : null;
$rows = array_values(array_filter(array_map('ticketRow', array_unique(array_map('intval', $ticket_ids)))));
if (!$agent || !$rows) {
return 0;
}
$app = $config_app_name ?: 'ITFlow';
$agent['footnote'] = 'You get this because ' . (count($rows) === 1 ? 'the ticket was' : 'they were') . ' assigned to you in ITFlow.';
if (count($rows) === 1) {
$row = $rows[0];
return ticketMailAgents([$agent], "$app - Ticket " . ticketReference($row) . " assigned to you - " . $row['ticket_subject'], ticketAgentEmailContent($row, 'assigned', ['by' => $by]));
}
$lines = [];
foreach ($rows as $row) {
$lines[] = [['text' => ticketReference($row), 'url' => ticketAgentUrl($row)], (string) $row['ticket_subject'], !empty($row['client_name']) ? (string) $row['client_name'] : '-', (string) $row['ticket_priority']];
}
return ticketMailAgents([$agent], "$app - " . count($rows) . " tickets have been assigned to you", [
'kind' => 'Assigned to you', 'tone' => 'primary', 'preview' => "$by assigned " . count($rows) . ' tickets to you',
'title' => count($rows) . ' tickets are yours',
'intro' => ["$by assigned these tickets to you."],
'lines' => ['head' => ['Ticket', 'Subject', 'Client', 'Priority'], 'rows' => $lines],
'button' => ['label' => 'Open your tickets', 'url' => "https://$config_base_url/agent/tickets.php?assigned=" . intval($user_id)],
]);
}
/*
* Tell an agent recurring tickets are theirs now - the tickets they raise
* will be. $subjects (raw) of the recurring tickets, $by who did it. Not when
* they did it themselves.
*/
function recurringTicketMailAssigned($user_id, array $subjects, $by) {
global $session_user_id, $config_app_name, $config_base_url;
$agent = intval($user_id) !== intval($session_user_id ?? 0) ? ticketMailAgent($user_id) : null;
if (!$agent || !$subjects) {
return 0;
}
$count = count($subjects);
$agent['footnote'] = 'You get this because ' . ($count === 1 ? 'it was' : 'they were') . ' assigned to you in ITFlow.';
return ticketMailAgents([$agent], ($config_app_name ?: 'ITFlow') . ($count === 1 ? " - Recurring ticket " . $subjects[0] . " assigned to you" : " - $count recurring tickets assigned to you"), [
'kind' => 'Assigned to you', 'tone' => 'primary', 'preview' => "$by assigned recurring tickets to you",
'title' => $count === 1 ? 'A recurring ticket is yours' : "$count recurring tickets are yours",
'intro' => ["$by assigned " . ($count === 1 ? 'this recurring ticket' : 'these recurring tickets') . " to you - the tickets " . ($count === 1 ? 'it raises' : 'they raise') . " will be yours."],
'lines' => ['head' => ['Recurring ticket'], 'rows' => array_map(function ($subject) { return [(string) $subject]; }, array_values($subjects))],
'button' => ['label' => 'Open Recurring Tickets', 'url' => "https://$config_base_url/agent/recurring_tickets.php?assigned_agent=" . intval($user_id)],
]);
}
/*
* Ask for a task's approval by email - the agent named, or the client's: the
* ticket's contact, or its technical or billing contacts. $by (raw) asked.
* How many it went to.
*/
function ticketMailTaskApproval($approval_id, $by) {
global $mysqli, $config_base_url, $session_user_id;
$approval = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT approval_id, approval_scope, approval_type, approval_required_user_id, approval_url_key, task_name, task_ticket_id
FROM task_approvals JOIN tasks ON task_id = approval_task_id WHERE approval_id = " . intval($approval_id)));
$row = $approval ? ticketRow($approval['task_ticket_id']) : null;
if (!$row) {
return 0;
}
$reference = ticketReference($row);
$client_id = intval($row['ticket_client_id']);
$type = (string) $approval['approval_type'];
$recipients = [];
if ($approval['approval_scope'] === 'internal' && $type === 'specific' && intval($approval['approval_required_user_id']) !== intval($session_user_id)) {
if ($agent = ticketMailAgent($approval['approval_required_user_id'])) {
$recipients[] = $agent + ['footnote' => "You get this because $by asked you to approve it."];
}
} elseif ($approval['approval_scope'] === 'client') {
$where = $type === 'technical' ? "contact_client_id = $client_id AND contact_technical = 1"
: ($type === 'billing' ? "contact_client_id = $client_id AND contact_billing = 1" : "contact_id = " . intval($row['ticket_contact_id']));
$sql_contacts = mysqli_query($mysqli, "SELECT contact_name, contact_email FROM contacts WHERE $where AND contact_archived_at IS NULL AND contact_email != ''");
while ($contact = mysqli_fetch_assoc($sql_contacts)) {
$recipients[] = ['email' => $contact['contact_email'], 'name' => $contact['contact_name'],
'footnote' => $type === 'any' ? "You get this as the contact on ticket $reference." : "You get this as a $type contact for " . $row['client_name'] . "."];
}
}
return count(emailQueue($recipients, "Ticket task approval required - [$reference] - " . $row['ticket_subject'], [
'marker' => true, 'kind' => 'Approval needed', 'tone' => 'warning', 'preview' => "$by needs your approval - " . $approval['task_name'],
'title' => 'Your approval is needed',
'intro' => ["$by needs your approval for a task on ticket $reference before the work goes ahead."],
'summary' => ['Task' => (string) $approval['task_name'], 'Ticket' => $reference, 'Subject' => (string) $row['ticket_subject'], 'Status' => (string) $row['ticket_status_name']],
'button' => ['label' => 'Review and approve', 'url' => "https://$config_base_url/guest/guest_approve_ticket_task.php?task_approval_id=" . intval($approval['approval_id']) . '&url_key=' . rawurlencode((string) $approval['approval_url_key'])],
'after' => ['Want to know more first? Just reply to this email.'],
'contact' => ticketEmailContactLine(),
], ['from' => ticketEmailFrom()]));
}
/*
* Tell someone who replied to a closed ticket by email that it is closed, so
* their reply reached nobody - and how to raise a new one. $from_email raw.
*/
function ticketMailClosedReply($ticket_id, $from_email) {
$closed = ticketRow($ticket_id);
if (!$closed) {
return 0;
}
$support = ticketEmailFrom()['email'];
return count(emailQueue([['email' => (string) $from_email, 'greet' => '', 'footnote' => 'You get this because you replied to a closed ticket.']], "Action required: This ticket is already closed", [
'kind' => 'Ticket closed', 'tone' => 'warning', 'preview' => 'Your reply did not reach us - raise a new ticket instead',
'title' => 'That ticket is closed',
'intro' => ["You replied to ticket " . ticketReference($closed) . " - \"" . $closed['ticket_subject'] . "\" - which is closed, so your reply won't reach us.", "To raise a new ticket, send a new email to $support."],
'button' => ['label' => 'Email us', 'url' => "mailto:$support"],
'contact' => ticketEmailContactLine(),
], ['from' => ticketEmailFrom()]));
}
/*
* An SLA alert on a ticket - in the app, and by email to the SLA alert address
* (Admin > SLAs, $address) and the agent it is assigned to. $track response or
* resolution; $breached true once it was missed; $due when it is or was due.
*/
function ticketSlaAlert($ticket_id, $track, $breached, $due, $address) {
$row = ticketRow($ticket_id);
if (!$row) {
return 0;
}
$ticket_id = intval($row['ticket_id']);
$ticket_ref = ticketReference($row);
$subject_line = ucfirst($track) . ' SLA ' . ($breached ? 'breached' : 'at risk');
// appNotify inserts what it is given - escape at the boundary
appNotify("Ticket SLA", escapeSql("$subject_line - $ticket_ref - " . $row['ticket_subject']), "/agent/ticket.php?ticket_id=$ticket_id", intval($row['ticket_client_id']), $ticket_id);
$recipients = [];
if (filter_var($address, FILTER_VALIDATE_EMAIL)) {
$recipients[] = ['email' => $address, 'greet' => '', 'footnote' => 'You get this at the SLA alert address in Admin > SLAs.'];
}
// The assigned agent - once, if they are also the alert address
if ($agent = ticketMailAgent($row['ticket_assigned_to'])) {
$recipients[] = $agent + ['footnote' => "You get this as the agent $ticket_ref is assigned to."];
}
return ticketMailAgents($recipients, "$subject_line: $ticket_ref - " . $row['ticket_subject'],
ticketAgentEmailContent($row, 'sla', ['track' => $track, 'breached' => $breached, 'due' => $due]));
}
/** Time worked as posted - HH:MM:SS, or H:MM - normalised to HH:MM:SS; nothing sensible is 00:00:00. */
function ticketTimeWorked($input) {
if (preg_match('/^\s*(\d{1,3}):([0-5]?\d)(?::([0-5]?\d))?\s*$/', (string) $input, $match)) {
return sprintf('%02d:%02d:%02d', intval($match[1]), intval($match[2]), intval($match[3] ?? 0));
}
return '00:00:00';
}
/** Statuses a reply may leave the ticket in: the active ones but New and Closed (the system sets those), plus the one it has. */
function ticketReplyStatuses($current_status) {
global $mysqli;
$ids = [intval($current_status)];
$sql = mysqli_query($mysqli, "SELECT ticket_status_id FROM ticket_statuses WHERE ticket_status_id NOT IN (1, 5) AND ticket_status_active = 1");
while ($row = mysqli_fetch_row($sql)) {
$ids[] = intval($row[0]);
}
return array_values(array_unique($ids));
}
/** How many of a ticket's tasks are not done yet. */
function ticketOpenTaskCount($ticket_id) {
global $mysqli;
return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM tasks WHERE task_ticket_id = " . intval($ticket_id) . " AND task_completed_at IS NULL"))[0]);
}
/**
* Tickets for another page's list - open ones first, then newest - each with
* its status, agent and is_open. $where is SQL on tickets; $limit 0 is all.
*/
function ticketRows($where, $limit = 0) {
global $mysqli;
$limit = intval($limit);
$sql = mysqli_query($mysqli, "SELECT tickets.ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_priority, ticket_vendor_ticket_number,
ticket_created_at, ticket_updated_at, ticket_resolved_at, ticket_closed_at, ticket_assigned_to, ticket_client_id,
user_name, user_avatar, ticket_status_id, ticket_status_name, ticket_status_color
FROM tickets
LEFT JOIN users ON ticket_assigned_to = user_id
LEFT JOIN ticket_statuses ON ticket_status_id = ticket_status
WHERE ($where) AND ticket_archived_at IS NULL
ORDER BY (ticket_resolved_at IS NULL AND ticket_closed_at IS NULL) DESC, ticket_number DESC" . ($limit ? " LIMIT $limit" : ''));
$rows = [];
while ($row = mysqli_fetch_assoc($sql)) {
$row['is_open'] = ticketIsOpen($row);
$rows[] = $row;
}
return $rows;
}
/** The SQL for "tickets about this asset" - its primary asset or one of its others. */
function ticketAssetSql($asset_id) {
$asset_id = intval($asset_id);
return "tickets.ticket_asset_id = $asset_id OR tickets.ticket_id IN (SELECT ticket_assets.ticket_id FROM ticket_assets WHERE ticket_assets.asset_id = $asset_id)";
}
/** How many tickets match, and how many of those are open - [all, open]. */
function ticketCounts($where) {
global $mysqli;
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*), COALESCE(SUM(ticket_resolved_at IS NULL AND ticket_closed_at IS NULL), 0) FROM tickets WHERE ($where) AND ticket_archived_at IS NULL"));
return [intval($row[0]), intval($row[1])];
}
/**
* Tickets as a table - the one used by the asset, contact, site, vendor and
* project pages and their quick looks. Options: 'compact' (a quick look's
* table: no header, no priority), 'vendor_ref' (their reference for it, to
* copy), 'client' (a Client column, for lists across clients).
*/
function ticketTableHtml(array $rows, $options = []) {
$compact = !empty($options['compact']);
$vendor_ref = !empty($options['vendor_ref']);
$with_client = !empty($options['client']);
$html = '
| Ticket | Subject | ' . ($vendor_ref ? 'Their reference | ' : '') . ($with_client ? 'Client | ' : '') . 'Priority | Status | Assigned | Updated |
|---|---|---|---|---|---|---|---|
| ' . escapeHtml(ticketReference($row)) . ' | ' . '' . escapeHtml($row['ticket_subject']) . ' | '; if ($vendor_ref) { $html .= '' . ($their_ref !== '' ? '' . $their_ref . '' : '-') . ' | '; } if ($with_client) { $html .= '' . escapeHtml($row['client_name'] ?? '') . ' | '; } if (!$compact) { $html .= '' . ticketPriorityHtml($row['ticket_priority'], 'detail') . ' | '; } $html .= '' . ticketStatusHtml($row['ticket_status_name'] ?? '', $row['ticket_status_color'] ?? '', 'detail') . ' | ' . '' . formatAssignee($row['ticket_assigned_to'], $row['user_name'] ?? '', $row['user_avatar'] ?? '', $is_open) . ' | ' . '' . timeAgo($last) . ' | ' . '