Files
itflow/functions/ticket.php
johnnyq b5ceeb4eaf Bill sign-ups through the API and follow up when an invoice is paid - a lead that pays an invoice in full becomes a client however it is paid (online, autopay, a recorded payment or a batch), with the new A lead that pays an invoice becomes a client switch in Admin > Settings > Invoices and billing, on by default; a non-billable open ticket tied to an invoice (one raised through the API with ticket_invoice_id, or a quote's tickets once it becomes the invoice) gets an internal note when the invoice is paid in full, goes from On Hold back to Open, and its assigned agent is notified in the app and by email, while billable tickets whose time is on an invoice are left alone; new API endpoints recurring_invoices/create (lines from products or spelled out, the client's currency, frequency, start date, optional first invoice made at once and emailed to the primary and billing contacts, or marked Sent so its link can be paid, the answer including the invoice reference, amount, due date and pay link), recurring_invoices/read and portal_invites/create (sent from the company rather than the key's agent); tickets/create takes ticket_status (an active status other than Resolved and Closed), ticket_invoice_id (one of the client's own invoices) and ticket_notify agents to tell only the team; database 3.5.9 adds settings.config_invoice_paid_lead_to_client; invoicePaidFollowUp() runs from triggerCustomAction() on invoice_pay, with recurringInvoiceCreate(), billingInvoiceIssue() and the paid kind of ticketAgentEmailContent() in support; the Clients list reads each contract's term, so Renews soon shows again and its PHP warning is gone; changelog updated
2026-11-05 19:00:16 -05:00

2029 lines
107 KiB
PHP

<?php
/*
* Tickets - loading one, checking what it may link to, drawing it wherever
* another page lists tickets, deleting it for good, and the recurring
* schedules that raise them.
*
* A ticket belongs to a client (or to none, for internal work). What it
* links to - its contact, asset and other assets, site, vendor and project -
* must be that client's own; ticketClientOwnedId() is the one check, used by
* the handlers, the API and the cron alike. The change functions for
* priority, assignee, status and billing are in functions/ticket_fields.php.
*/
/** The built-in statuses (ticket_statuses rows 1-5) - the system sets New and Closed; Resolve sets Resolved. */
const TICKET_STATUS_NEW = 1;
const TICKET_STATUS_OPEN = 2;
const TICKET_STATUS_ON_HOLD = 3;
const TICKET_STATUS_RESOLVED = 4;
const TICKET_STATUS_CLOSED = 5;
/** How often a recurring ticket can run - what is stored, and how far one run is from the next. */
const RECURRING_TICKET_FREQUENCIES = [
'Three Days' => ['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 = '<a class="font-monospace text-nowrap" href="' . $url . '">' . $reference . '</a>';
if (($options['subject'] ?? true) && isset($row['ticket_subject'])) {
$html .= ' <a class="' . (ticketIsOpen($row) ? 'text-body' : 'text-body-secondary') . '" href="' . $url . '">' . escapeHtml($row['ticket_subject']) . '</a>';
}
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*<i>\s*Email from:.*?</i>(\s*<br\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 = '<div class="table-responsive"><table class="table ' . ($compact ? 'table-sm ' : '') . 'table-hover align-middle mb-0 itflow-ticket-table">';
if (!$compact) {
$html .= '<thead class="text-nowrap table-light"><tr><th>Ticket</th><th>Subject</th>' . ($vendor_ref ? '<th>Their reference</th>' : '')
. ($with_client ? '<th>Client</th>' : '') . '<th>Priority</th><th>Status</th><th>Assigned</th><th>Updated</th></tr></thead>';
}
$html .= '<tbody>';
foreach ($rows as $row) {
$is_open = $row['is_open'] ?? ticketIsOpen($row);
$url = escapeHtml(ticketUrl($row['ticket_id'], $row['ticket_client_id'] ?? 0));
$last = $row['ticket_updated_at'] ?: $row['ticket_created_at'];
$their_ref = escapeHtml($row['ticket_vendor_ticket_number'] ?? '');
$html .= '<tr class="' . ($is_open ? '' : 'text-body-secondary') . '">'
. '<td class="text-nowrap"><a class="font-monospace" href="' . $url . '">' . escapeHtml(ticketReference($row)) . '</a></td>'
. '<td class="' . ($compact ? 'w-100' : 'itflow-ticket-subject') . '"><a class="' . ($is_open ? 'text-body' . ($compact ? '' : ' fw-semibold') : 'text-body-secondary') . '" href="' . $url . '">' . escapeHtml($row['ticket_subject']) . '</a></td>';
if ($vendor_ref) {
$html .= '<td class="text-nowrap small">' . ($their_ref !== ''
? '<span class="font-monospace">' . $their_ref . '</span><button type="button" class="itflow-copy clipboardjs" title="Copy their reference" data-clipboard-text="' . $their_ref . '"><i class="far fa-copy"></i></button>'
: '<span class="text-body-secondary">-</span>') . '</td>';
}
if ($with_client) {
$html .= '<td class="small">' . escapeHtml($row['client_name'] ?? '') . '</td>';
}
if (!$compact) {
$html .= '<td class="text-nowrap">' . ticketPriorityHtml($row['ticket_priority'], 'detail') . '</td>';
}
$html .= '<td class="text-nowrap">' . ticketStatusHtml($row['ticket_status_name'] ?? '', $row['ticket_status_color'] ?? '', 'detail') . '</td>'
. '<td class="text-nowrap">' . formatAssignee($row['ticket_assigned_to'], $row['user_name'] ?? '', $row['user_avatar'] ?? '', $is_open) . '</td>'
. '<td class="text-nowrap small text-body-secondary" title="' . escapeHtml($last) . '">' . timeAgo($last) . '</td>'
. '</tr>';
}
return $html . '</tbody></table></div>';
}
/**
* The other open tickets for the same contact or the same asset as this one
* (or as the contact and asset given, for a ticket not yet created) - so a
* duplicate or a related problem shows up. Each row says which it shares.
*/
function ticketAlsoOpen($contact_id, $asset_id, $exclude_ticket_id = 0, $limit = 6) {
global $mysqli;
$contact_id = intval($contact_id);
$asset_id = intval($asset_id);
$exclude_ticket_id = intval($exclude_ticket_id);
$match = [];
if ($contact_id) {
$match[] = "tickets.ticket_contact_id = $contact_id";
}
if ($asset_id) {
$match[] = ticketAssetSql($asset_id);
}
if (!$match) {
return [];
}
$rows = ticketRows('(' . implode(' OR ', $match) . ") AND tickets.ticket_id <> $exclude_ticket_id AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL", $limit);
foreach ($rows as &$row) {
$shares = [];
$ticket_id = intval($row['ticket_id']);
$full = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_contact_id, ticket_asset_id FROM tickets WHERE ticket_id = $ticket_id"));
if ($contact_id && intval($full['ticket_contact_id']) === $contact_id) {
$shares[] = 'contact';
}
if ($asset_id && (intval($full['ticket_asset_id']) === $asset_id
|| mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM ticket_assets WHERE ticket_id = $ticket_id AND asset_id = $asset_id LIMIT 1")))) {
$shares[] = 'asset';
}
$row['shares'] = $shares;
}
return $rows;
}
/** Mark a ticket resolved - status, SLA, history, audit and the custom action. The caller has checked it may be. */
function ticketResolve($row, $how = '') {
global $mysqli, $session_name;
$ticket_id = intval($row['ticket_id']);
if (empty($row['ticket_first_response_at'])) {
setTicketFirstResponse($ticket_id);
}
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = " . TICKET_STATUS_RESOLVED . ", ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id");
syncTicketSlaClock($ticket_id);
setTicketResolutionSlaMet($ticket_id);
logTicketHistory($ticket_id, "$session_name resolved the ticket$how");
logAudit("Ticket", "Resolve", "$session_name resolved ticket " . escapeSql(ticketReference($row)) . " - " . escapeSql($row['ticket_subject']), intval($row['ticket_client_id']), $ticket_id);
triggerCustomAction('ticket_resolve', $ticket_id);
}
/** Bring a resolved (not closed) ticket back into a working status. The caller has checked it may be. */
function ticketReopen($row, $status_id = TICKET_STATUS_OPEN, $how = '') {
global $mysqli, $session_name;
$ticket_id = intval($row['ticket_id']);
$status_id = intval($status_id);
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = $status_id, ticket_resolved_at = NULL WHERE ticket_id = $ticket_id");
syncTicketSlaClock($ticket_id);
resetTicketResolutionSla($ticket_id);
$status_name = escapeSql(getTicketStatusName($status_id));
logTicketHistory($ticket_id, "$session_name reopened the ticket" . ($status_id === TICKET_STATUS_OPEN ? '' : " to $status_name") . $how);
logAudit("Ticket", "Reopen", "$session_name reopened ticket " . escapeSql(ticketReference($row)), intval($row['ticket_client_id']), $ticket_id);
triggerCustomAction('ticket_update', $ticket_id);
}
/** The "your ticket is closed" email - to its contact and watchers, asking how we did. */
function ticketMailClosed($ticket_id) {
$row = ticketRow($ticket_id);
if (!$row) {
return 0;
}
return ticketMailSend($row['ticket_id'], "Ticket closed - [" . ticketReference($row) . "] - " . $row['ticket_subject'] . " | (do not reply)",
ticketEmailContent($row, 'closed'), ['kind' => 'closed']);
}
/*
* The "Emailed to ..." line on an agent's reply in the conversation, and the
* panel it opens - each recipient and whether their email went. $state is
* the reply's from ticketReplyEmailStates(); $queue_url the Mail Queue for
* this ticket's email, for admins, or ''. ['line' => html, 'panel' => html].
*/
function ticketReplyEmailHtml($reply_id, array $state, $queue_url = '') {
$reply_id = intval($reply_id);
$to = $state['to'] ?? [];
if (!$to) {
// Marked emailed, but who to wasn't kept
return ['line' => '<span class="ticket-reply-emailed"><i class="fas fa-fw fa-paper-plane me-1"></i>Emailed</span>', 'panel' => ''];
}
$who = function ($recipient) {
return $recipient['name'] !== '' ? $recipient['name'] : $recipient['email'];
};
$first = escapeHtml($who($to[0])) . (count($to) > 1 ? ' and ' . (count($to) - 1) . ' more' : '');
$failed = count(array_filter($to, function ($recipient) {
return ($recipient['state']['key'] ?? '') === 'failed';
}));
switch ($state['key'] ?? 'gone') {
case 'sent':
[$icon, $text, $title] = ['fa-check text-success', "Emailed to $first", 'Sent'];
break;
case 'waiting':
[$icon, $text, $title] = ['fa-hourglass-half text-primary', "Sending to $first", 'Waiting in the mail queue'];
break;
case 'failed':
[$icon, $text, $title] = ['fa-exclamation-circle text-danger', $failed === count($to) ? "<span class=\"text-danger\">Couldn't email $first</span>"
: "Emailed to $first <span class=\"text-danger\">&middot; $failed not sent</span>", 'Not sent'];
break;
case 'cancelled':
[$icon, $text, $title] = ['fa-ban', "Email to $first cancelled", 'Cancelled'];
break;
default:
[$icon, $text, $title] = ['fa-paper-plane', "Emailed to $first", ''];
}
$line = '<a class="ticket-reply-emailed text-reset" data-bs-toggle="collapse" href="#replyEmailed' . $reply_id . '" role="button" aria-expanded="false" aria-controls="replyEmailed' . $reply_id . '"'
. ' title="' . escapeHtml(($title !== '' ? "$title - " : '') . 'who it went to') . '" data-reply-emailed="' . escapeHtml($state['key'] ?? 'gone') . '">'
. '<i class="fas fa-fw ' . $icon . ' me-1"></i>' . $text . '</a>';
$items = '';
foreach ($to as $recipient) {
$item_state = $recipient['state'];
$address = $recipient['name'] !== '' ? escapeHtml($recipient['name']) . ' <span class="text-body-secondary">&lt;' . escapeHtml($recipient['email']) . '&gt;</span>' : escapeHtml($recipient['email']);
$items .= '<li class="d-flex flex-wrap align-items-center gap-2" data-reply-recipient="' . escapeHtml($recipient['email']) . '">'
. ($item_state ? '<span class="badge ' . $item_state['badge'] . '" title="' . escapeHtml($item_state['title']) . '"><i class="fas fa-fw ' . $item_state['icon'] . ' me-1"></i>' . escapeHtml($item_state['label']) . '</span>' : '')
. '<span class="text-break">' . $address . '</span>'
. ($item_state && $item_state['detail'] !== '' ? '<span class="text-body-secondary">' . escapeHtml($item_state['detail']) . '</span>' : '')
. ($item_state && $item_state['error'] !== '' ? '<span class="w-100 text-danger">' . escapeHtml($item_state['error']) . '</span>' : '')
. '</li>';
}
$gone = !array_filter(array_column($to, 'state'));
$panel = '<div class="collapse" id="replyEmailed' . $reply_id . '">'
. '<div class="ticket-reply-recipients small mt-2">'
. '<ul class="list-unstyled d-grid gap-1 mb-0">' . $items . '</ul>'
. ($gone ? '<div class="text-body-secondary mt-1">The mail queue keeps email ' . MAIL_QUEUE_KEEP_DAYS . ' days, so whether each one went is no longer known.</div>' : '')
. ($queue_url !== '' ? '<a class="d-inline-block mt-1" href="' . escapeHtml($queue_url) . '"><i class="fas fa-fw fa-mail-bulk me-1"></i>This ticket\'s email in the Mail Queue</a>' : '')
. '</div></div>';
return ['line' => $line, 'panel' => $panel];
}
/*
* The "your ticket is resolved, pending closure" email - to its contact and
* watchers. $note is the agent's words as HTML, raw, or ''; $reply_id the
* reply they were saved as, which keeps who it went to (ticketReplyEmailed()).
*/
function ticketMailResolved($ticket_id, $note = '', $reply_id = 0) {
global $session_name;
$row = ticketRow($ticket_id);
if (!$row) {
ticketReplyEmailed($reply_id, false);
return 0;
}
$sent = ticketMailSend($row['ticket_id'], "Ticket resolved - [" . ticketReference($row) . "] - " . $row['ticket_subject'] . " | (pending closure)",
ticketEmailContent($row, 'resolved', ['message' => (string) $note, 'by' => stripslashes((string) ($session_name ?? ''))]), ['ticket_reply_id' => intval($reply_id)]);
ticketReplyEmailed($reply_id, $sent > 0);
return $sent;
}
/*
* ---------- the ticket list and board ----------
*
* Whose move it is. A ticket is waiting on us until we reply where the
* client can see it; after that it waits on them until they write back.
* Internal notes change neither. A status that pauses the SLA clock (On
* Hold, or one of yours like "Waiting on vendor") parks it - waiting, too.
*/
/** SQL: still being worked - neither resolved nor closed. On tickets. */
const TICKET_OPEN_SQL = "(tickets.ticket_resolved_at IS NULL AND tickets.ticket_closed_at IS NULL)";
/**
* SQL for who spoke last where the client can see it: 'Client', 'Public'
* (us), or NULL when nobody has replied yet. Correlated on tickets, so it
* goes after the open-ticket test in a condition - MySQL stops at the first
* false AND, and it then only runs for open tickets.
*/
function ticketLastSpokeSql() {
return "(SELECT tr.ticket_reply_type FROM ticket_replies tr WHERE tr.ticket_reply_ticket_id = tickets.ticket_id
AND tr.ticket_reply_archived_at IS NULL AND tr.ticket_reply_type IN ('Public', 'Client')
ORDER BY tr.ticket_reply_id DESC LIMIT 1)";
}
/** SQL: open, not parked, and the client spoke last (or nobody has yet). Needs tickets joined to ticket_statuses. */
function ticketNeedsReplySql() {
return TICKET_OPEN_SQL . " AND COALESCE(ticket_statuses.ticket_status_pauses_sla, 0) = 0 AND COALESCE(" . ticketLastSpokeSql() . ", 'Client') = 'Client'";
}
/** SQL: open, and parked or we spoke last. Needs tickets joined to ticket_statuses. */
function ticketWaitingSql() {
return TICKET_OPEN_SQL . " AND (COALESCE(ticket_statuses.ticket_status_pauses_sla, 0) = 1 OR " . ticketLastSpokeSql() . " = 'Public')";
}
/** SQL: open and past its due date, or its SLA is breached. */
function ticketOverdueSql() {
return TICKET_OPEN_SQL . " AND ((tickets.ticket_due_at IS NOT NULL AND tickets.ticket_due_at < NOW())
OR (tickets.ticket_sla_id > 0 AND (tickets.ticket_response_sla_alert_stage = 2 OR tickets.ticket_resolution_sla_alert_stage = 2)))";
}
/** Whose move: 'us', 'client', 'parked', or '' for a ticket that is done. From a row with ticket_last_spoke and ticket_status_pauses_sla. */
function ticketWhoseMove($row) {
if (!ticketIsOpen($row)) {
return '';
}
if (intval($row['ticket_status_pauses_sla'] ?? 0)) {
return 'parked';
}
return ($row['ticket_last_spoke'] ?? null) === 'Public' ? 'client' : 'us';
}
/**
* Where its SLA stands: ['key' => breached | at_risk | paused | running |
* met | missed | none, 'due' => the clock still running's deadline, 'what'].
* A breach recorded stands even while paused - it already happened.
*/
function ticketSlaState($row) {
if (!intval($row['ticket_sla_id'] ?? 0)) {
return ['key' => 'none'];
}
$stage = max(intval($row['ticket_response_sla_alert_stage'] ?? 0), intval($row['ticket_resolution_sla_alert_stage'] ?? 0));
if (!ticketIsOpen($row)) {
if ($stage === 2 || ($row['ticket_response_sla_met'] ?? null) === '0' || ($row['ticket_resolution_sla_met'] ?? null) === '0') {
return ['key' => 'missed'];
}
$met = intval($row['ticket_response_sla_met'] ?? 0) && (empty($row['ticket_resolution_due_at']) || intval($row['ticket_resolution_sla_met'] ?? 0));
return ['key' => $met ? 'met' : 'none'];
}
$responded = !empty($row['ticket_first_response_at']);
$state = [
'due' => $responded ? ($row['ticket_resolution_due_at'] ?? null) : ($row['ticket_response_due_at'] ?? null),
'what' => $responded ? 'Resolution due' : 'First response due',
];
if ($stage === 2) {
return ['key' => 'breached'] + $state;
}
if (intval($row['ticket_status_pauses_sla'] ?? 0)) {
return ['key' => 'paused'] + $state;
}
if ($stage === 1) {
return ['key' => 'at_risk'] + $state;
}
return ['key' => empty($state['due']) ? 'none' : 'running'] + $state;
}
/** The SLA as a small pill - '' when there is nothing to say. */
function ticketSlaPill($sla) {
$due = !empty($sla['due']) ? ' title="' . escapeHtml($sla['what'] . ' ' . $sla['due']) . '"' : '';
switch ($sla['key']) {
case 'breached':
return '<span class="badge rounded-pill text-bg-danger"' . $due . '><i class="fas fa-stopwatch me-1"></i>Breached</span>';
case 'at_risk':
return '<span class="badge rounded-pill text-bg-warning"' . $due . '><i class="fas fa-stopwatch me-1"></i>' . (!empty($sla['due']) ? timeAgo($sla['due']) : 'At risk') . '</span>';
case 'paused':
return '<span class="badge rounded-pill text-bg-secondary" title="The SLA clock is paused in this status"><i class="fas fa-pause me-1"></i>Paused</span>';
case 'running':
return '<span class="badge rounded-pill bg-body-secondary text-body-secondary fw-normal"' . $due . '><i class="fas fa-stopwatch me-1"></i>' . timeAgo($sla['due']) . '</span>';
case 'met':
return '<span class="badge rounded-pill bg-success-subtle text-success-emphasis fw-normal"><i class="fas fa-check me-1"></i>SLA met</span>';
case 'missed':
return '<span class="badge rounded-pill bg-danger-subtle text-danger-emphasis fw-normal"><i class="fas fa-stopwatch me-1"></i>SLA missed</span>';
}
return '';
}
/** Its own due date, for an open ticket: ['key' => overdue | soon (a day or less) | later, 'at'], or null. */
function ticketDueState($row) {
if (empty($row['ticket_due_at']) || !ticketIsOpen($row)) {
return null;
}
$left = strtotime($row['ticket_due_at']) - time();
return ['key' => $left < 0 ? 'overdue' : ($left <= 86400 ? 'soon' : 'later'), 'at' => $row['ticket_due_at']];
}
function ticketDuePill($due) {
if (!$due) {
return '';
}
$title = ' title="Due ' . escapeHtml(date('M j, Y g:i A', strtotime($due['at']))) . '"';
if ($due['key'] === 'overdue') {
return '<span class="badge rounded-pill text-bg-danger"' . $title . '><i class="far fa-calendar-times me-1"></i>Due ' . timeAgo($due['at']) . '</span>';
}
return '<span class="badge rounded-pill ' . ($due['key'] === 'soon' ? 'text-bg-warning' : 'bg-body-secondary text-body-secondary fw-normal') . '"' . $title . '><i class="far fa-calendar me-1"></i>Due ' . timeAgo($due['at']) . '</span>';
}
/** The edge a row or card gets: danger (late), warning (close to it) or ''. */
function ticketAttention($sla, $due) {
if ($sla['key'] === 'breached' || ($due['key'] ?? '') === 'overdue') {
return 'danger';
}
if ($sla['key'] === 'at_risk' || ($due['key'] ?? '') === 'soon') {
return 'warning';
}
return '';
}
/** The last reply on each of these tickets, any kind, with who wrote it - [ticket id => row]. */
function ticketLastReplies(array $ticket_ids) {
global $mysqli;
$ids = implode(',', array_filter(array_map('intval', $ticket_ids)));
if ($ids === '') {
return [];
}
$sql = mysqli_query($mysqli, "SELECT ticket_reply_ticket_id, ticket_reply_type, ticket_reply_created_at, ticket_reply_by,
contact_name, contact_photo, contact_client_id, user_name, user_avatar
FROM ticket_replies
LEFT JOIN users ON ticket_reply_type <> 'Client' AND ticket_reply_by = user_id
LEFT JOIN contacts ON ticket_reply_type = 'Client' AND ticket_reply_by = contact_id
WHERE ticket_reply_id IN (SELECT MAX(ticket_reply_id) FROM ticket_replies
WHERE ticket_reply_ticket_id IN ($ids) AND ticket_reply_archived_at IS NULL GROUP BY ticket_reply_ticket_id)");
$rows = [];
while ($row = mysqli_fetch_assoc($sql)) {
$rows[intval($row['ticket_reply_ticket_id'])] = $row;
}
return $rows;
}
/** Tasks on each of these tickets - [ticket id => [done, all]]. */
function ticketTaskCounts(array $ticket_ids) {
global $mysqli;
$ids = implode(',', array_filter(array_map('intval', $ticket_ids)));
if ($ids === '') {
return [];
}
$sql = mysqli_query($mysqli, "SELECT task_ticket_id, COUNT(task_id) AS task_count, SUM(task_completed_at IS NOT NULL) AS done_count
FROM tasks WHERE task_ticket_id IN ($ids) GROUP BY task_ticket_id");
$counts = [];
while ($row = mysqli_fetch_assoc($sql)) {
$counts[intval($row['task_ticket_id'])] = [intval($row['done_count']), intval($row['task_count'])];
}
return $counts;
}
/** A small bar and "2/5" for a ticket's tasks - '' when it has none. */
function ticketTasksHtml($counts) {
if (!$counts || !$counts[1]) {
return '';
}
[$done, $all] = $counts;
$percent = round($done / $all * 100);
return '<span class="itflow-ticket-tasks" title="' . "$done of $all tasks done" . '">'
. '<span class="progress" role="progressbar" aria-valuenow="' . $percent . '" aria-valuemin="0" aria-valuemax="100"><span class="progress-bar ' . ($done === $all ? 'bg-success' : 'bg-secondary') . '" style="width: ' . $percent . '%"></span></span>'
. "$done/$all</span>";
}
/**
* The last reply as one line - when, and who - with an icon for which way it
* went. The whole thing is what the list's Activity column and the board's
* cards show.
*/
function ticketActivityHtml($reply, $row) {
if (!$reply) {
$created = $row['ticket_created_at'];
return '<span class="text-body-secondary" title="Opened ' . escapeHtml($created) . '"><i class="far fa-fw fa-clock me-1"></i>No replies</span>';
}
$type = $reply['ticket_reply_type'];
$who = $type === 'Client' ? ($reply['contact_name'] ?? '') : ($reply['user_name'] ?? '');
$icons = [
'Client' => ['fa-reply text-warning', 'The client replied'],
'Public' => ['fa-paper-plane text-body-secondary', 'We replied'],
'Internal' => ['fa-sticky-note text-body-secondary', 'Internal note'],
];
[$icon, $label] = $icons[$type] ?? $icons['Internal'];
return '<span title="' . escapeHtml($label . ($who !== '' ? " - $who" : '') . ', ' . $reply['ticket_reply_created_at']) . '">'
. '<i class="fas fa-fw ' . $icon . ' me-1"></i>' . timeAgo($reply['ticket_reply_created_at']) . '</span>'
. ($who !== '' ? '<div class="small text-body-secondary text-truncate itflow-ticket-who">' . escapeHtml($who) . '</div>' : '');
}
/** Where it came in from, as an icon - '' for one an agent raised. */
function ticketSourceIcon($source) {
$icons = ['Email' => ['fa-envelope', 'Came in by email'], 'Portal' => ['fa-globe', 'Raised in the client portal'],
'Recurring' => ['fa-redo-alt', 'Raised by a recurring ticket'], 'API' => ['fa-plug', 'Raised through the API'],
'Project' => ['fa-project-diagram', 'From a project template']];
foreach ($icons as $match => [$icon, $title]) {
if (stripos((string) $source, $match) !== false) {
return '<i class="fas fa-fw ' . $icon . '" title="' . $title . '"></i>';
}
}
return '';
}
/*
* ---------- recurring tickets ----------
*/
/** One recurring ticket by id, with its client - raw, or null. */
function recurringTicketRow($recurring_ticket_id) {
global $mysqli;
$recurring_ticket_id = intval($recurring_ticket_id);
if (!$recurring_ticket_id) {
return null;
}
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT recurring_tickets.*, client_name FROM recurring_tickets
LEFT JOIN clients ON client_id = recurring_ticket_client_id
WHERE recurring_ticket_id = $recurring_ticket_id LIMIT 1"));
return $row ?: null;
}
/** A frequency as posted, or '' when it isn't one. */
function recurringTicketFrequency($frequency) {
foreach (array_keys(RECURRING_TICKET_FREQUENCIES) as $known) {
if (strcasecmp(trim((string) $frequency), $known) === 0) {
return $known;
}
}
return '';
}
/**
* The next run after $after (today by default), stepping from the run that
* was due. Month-based schedules keep to the day of the month the run that
* was due fell on - except when that was a shorter month's last day standing
* in for a later one: one that started ($anchor) on the 31st runs on the last
* day of shorter months and back on the 31st after, rather than drifting.
* Missed runs are stepped over, not raised one by one.
*/
function recurringTicketNextRun($due, $frequency, $anchor = '', $after = '') {
$frequency = recurringTicketFrequency($frequency) ?: 'Monthly';
$step = RECURRING_TICKET_FREQUENCIES[$frequency];
$after = $after !== '' ? $after : date('Y-m-d');
$date = DateTime::createFromFormat('!Y-m-d', substr((string) $due, 0, 10)) ?: new DateTime('today');
$anchor_date = DateTime::createFromFormat('!Y-m-d', substr((string) $anchor, 0, 10));
$anchor_day = intval($date->format('j'));
if ($anchor_date && intval($anchor_date->format('j')) > $anchor_day && $anchor_day === intval($date->format('t'))) {
$anchor_day = intval($anchor_date->format('j'));
}
$guard = 0;
do {
if (isset($step['days'])) {
$date->modify('+' . $step['days'] . ' days');
} else {
$year = intval($date->format('Y'));
$month = intval($date->format('n')) + $step['months'];
$year += intdiv($month - 1, 12);
$month = ($month - 1) % 12 + 1;
$days_in_month = intval(date('t', mktime(0, 0, 0, $month, 1, $year)));
$date->setDate($year, $month, min($anchor_day, $days_in_month));
}
} while ($date->format('Y-m-d') <= $after && ++$guard < 5000);
return $date->format('Y-m-d');
}
/** When in the week or month it comes round, in words - "on Mondays", "on the 15th", "on the last day of the month". */
function recurringTicketCadenceText($row) {
$frequency = recurringTicketFrequency($row['recurring_ticket_frequency'] ?? '') ?: 'Monthly';
$next = DateTime::createFromFormat('!Y-m-d', substr((string) ($row['recurring_ticket_next_run'] ?? ''), 0, 10));
if (!$next) {
return '';
}
$step = RECURRING_TICKET_FREQUENCIES[$frequency];
if (isset($step['days'])) {
return $step['days'] === 7 ? 'on ' . $next->format('l') . 's' : ($step['days'] === 14 ? 'every other ' . $next->format('l') : 'every three days');
}
$day = intval($next->format('j'));
$start = DateTime::createFromFormat('!Y-m-d', substr((string) ($row['recurring_ticket_start_date'] ?? ''), 0, 10));
if ($start && intval($start->format('j')) > $day && $day === intval($next->format('t'))) {
$day = intval($start->format('j'));
}
if ($day >= 31 || ($day >= 29 && $day === intval($next->format('t')) && (!$start || intval($start->format('j')) >= 31))) {
return 'on the last day of the month';
}
$suffix = ($day % 100 >= 11 && $day % 100 <= 13) ? 'th' : (['th', 'st', 'nd', 'rd'][$day % 10] ?? 'th');
return "on the $day$suffix";
}
/** Is a recurring ticket paused? */
function recurringTicketPaused($row) {
return !empty($row['recurring_ticket_paused_at']);
}
/** When it runs next, in words - "Today", "Tomorrow", "In 3 days", "Missed - was due 2 days ago", or "Paused". */
function recurringTicketNextRunText($row) {
if (recurringTicketPaused($row)) {
return ['text' => 'Paused', 'tone' => 'secondary', 'icon' => 'fa-pause'];
}
$next = DateTime::createFromFormat('!Y-m-d', substr((string) $row['recurring_ticket_next_run'], 0, 10));
if (!$next) {
return ['text' => 'Not scheduled', 'tone' => 'secondary', 'icon' => 'fa-question'];
}
$days = intval((new DateTime('today'))->diff($next)->format('%r%a'));
if ($days < 0) {
return ['text' => 'Missed - was due ' . ($days === -1 ? 'yesterday' : abs($days) . ' days ago'), 'tone' => 'danger', 'icon' => 'fa-exclamation-triangle'];
}
if ($days === 0) {
return ['text' => 'Today', 'tone' => 'warning', 'icon' => 'fa-clock'];
}
if ($days === 1) {
return ['text' => 'Tomorrow', 'tone' => 'info', 'icon' => 'fa-clock'];
}
return ['text' => $days <= 14 ? "In $days days" : timeAgo($next->format('Y-m-d')), 'tone' => $days <= 7 ? 'info' : 'success', 'icon' => 'fa-calendar-alt'];
}
/** The next run as a soft pill. */
function recurringTicketNextRunPill($row) {
$state = recurringTicketNextRunText($row);
$tone = $state['tone'];
return '<span class="badge rounded-pill bg-' . $tone . '-subtle text-' . $tone . '-emphasis border border-' . $tone . '-subtle fw-normal" title="' . escapeHtml((string) $row['recurring_ticket_next_run']) . '">'
. '<i class="fas ' . $state['icon'] . ' me-1"></i>' . escapeHtml($state['text']) . '</span>';
}
/**
* Raise the ticket a recurring ticket describes, now: its subject, details,
* priority, category, agent, contact, asset and other assets, billing and
* tasks, with the SLA applied, the client told (when general notifications
* are on) and the new-ticket address told. $by is who did it, for the audit
* log. Does not move the next run - the caller does. The new ticket's id, or 0.
*/
function recurringTicketRaise($recurring_ticket_id, $by = 'Cron') {
global $mysqli, $config_ticket_client_general_notifications;
$row = recurringTicketRow($recurring_ticket_id);
if (!$row) {
return 0;
}
// The settings, read here: the cron keeps its own copies SQL-escaped, the pages don't
$settings = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_ticket_prefix, config_ticket_from_email, config_ticket_from_name,
config_ticket_new_ticket_notification_email, config_ticket_client_general_notifications, config_smtp_provider FROM settings WHERE company_id = 1"));
$config_ticket_prefix = $settings['config_ticket_prefix'] ?? '';
$config_ticket_from_email = $settings['config_ticket_from_email'] ?? '';
$config_ticket_from_name = $settings['config_ticket_from_name'] ?? '';
$config_ticket_new_ticket_notification_email = $settings['config_ticket_new_ticket_notification_email'] ?? '';
$config_ticket_client_general_notifications = intval($settings['config_ticket_client_general_notifications'] ?? 0);
$config_smtp_provider = $settings['config_smtp_provider'] ?? '';
$recurring_ticket_id = intval($row['recurring_ticket_id']);
$client_id = intval($row['recurring_ticket_client_id']);
$subject = escapeSql($row['recurring_ticket_subject']);
$details = mysqli_real_escape_string($mysqli, (string) $row['recurring_ticket_details']);
$priority = escapeSql(ticketPriority($row['recurring_ticket_priority']));
$frequency = strtolower((string) $row['recurring_ticket_frequency']);
$billable = intval($row['recurring_ticket_billable']);
$created_by = intval($row['recurring_ticket_created_by']);
$assigned_to = ticketAgentId($row['recurring_ticket_assigned_to']);
// What it links to has to still be the client's
$contact_id = ticketClientOwnedId('contact', $row['recurring_ticket_contact_id'], $client_id);
$asset_id = ticketClientOwnedId('asset', $row['recurring_ticket_asset_id'], $client_id);
$category_id = ticketCategoryId($row['recurring_ticket_category']);
$ticket_status = $assigned_to ? TICKET_STATUS_OPEN : TICKET_STATUS_NEW;
$url_key = randomString(32);
$prefix = escapeSql($config_ticket_prefix);
// Atomically increment and get the new ticket number
mysqli_query($mysqli, "UPDATE settings SET config_ticket_next_number = LAST_INSERT_ID(config_ticket_next_number), config_ticket_next_number = config_ticket_next_number + 1 WHERE company_id = 1");
$ticket_number = mysqli_insert_id($mysqli);
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$prefix', ticket_number = $ticket_number, ticket_source = 'Recurring', ticket_subject = '$subject',
ticket_details = '$details', ticket_priority = '$priority', ticket_status = $ticket_status, ticket_billable = $billable, ticket_url_key = '$url_key',
ticket_created_by = $created_by, ticket_assigned_to = $assigned_to, ticket_contact_id = $contact_id, ticket_client_id = $client_id,
ticket_asset_id = $asset_id, ticket_category = $category_id, ticket_recurring_ticket_id = $recurring_ticket_id");
$ticket_id = mysqli_insert_id($mysqli);
if (!$ticket_id) {
return 0;
}
applyTicketSla($ticket_id);
// Its other assets that are still the client's, and its tasks
mysqli_query($mysqli, "INSERT IGNORE INTO ticket_assets (ticket_id, asset_id)
SELECT $ticket_id, recurring_ticket_assets.asset_id FROM recurring_ticket_assets
JOIN assets ON assets.asset_id = recurring_ticket_assets.asset_id AND asset_client_id = $client_id
WHERE recurring_ticket_id = $recurring_ticket_id AND recurring_ticket_assets.asset_id <> $asset_id");
addTasksFromRecurringTicket($ticket_id, $recurring_ticket_id);
// The template it was made from, kept for the template's use, the answers it was given for
// what the template asks, and its [Client], [Month] and the like filled in - every time
// it is raised (functions/ticket_template.php)
$template_id = intval($row['recurring_ticket_ticket_template_id'] ?? 0);
if ($template_id && ticketTemplateRow($template_id)) {
mysqli_query($mysqli, "UPDATE tickets SET ticket_template_id = $template_id WHERE ticket_id = $ticket_id");
}
$fill_ins = ticketFillInsStored('recurring_ticket', $recurring_ticket_id);
if ($fill_ins) {
ticketFillInsStore('ticket', $ticket_id, $fill_ins);
}
ticketPlaceholdersFill($ticket_id);
logAudit("Ticket", "Create", escapeSql($by) . " created recurring $frequency ticket $prefix$ticket_number - $subject", $client_id, $ticket_id);
triggerCustomAction('ticket_create', $ticket_id);
// The client is told (when general notifications are on) - the contact alone
$new = ticketRow($ticket_id);
if (intval($config_ticket_client_general_notifications) === 1 && $new) {
ticketMailSend($ticket_id, "Ticket created - [" . ticketReference($new) . "] - " . $new['ticket_subject'] . " (scheduled)",
ticketEmailContent($new, 'created', ['how' => 'scheduled']), ['kind' => 'created', 'to_watchers' => false]);
}
// The new-ticket address (Admin > Ticket), when there is one
ticketMailNewTicketAddress($ticket_id, 'scheduled');
return $ticket_id;
}
/**
* Raise a recurring ticket's ticket now and move its next run on: one step
* past the run that was due, or past today when that was missed - so forcing
* an early run takes the place of the next one, and nothing is raised twice.
* The new ticket's id, or 0.
*/
function recurringTicketRunNow($row, $by) {
global $mysqli;
$recurring_ticket_id = intval($row['recurring_ticket_id']);
$ticket_id = recurringTicketRaise($recurring_ticket_id, $by);
if ($ticket_id) {
$due = (string) $row['recurring_ticket_next_run'];
$next_run = recurringTicketNextRun($due, $row['recurring_ticket_frequency'], $row['recurring_ticket_start_date'], max(date('Y-m-d'), substr($due, 0, 10)));
mysqli_query($mysqli, "UPDATE recurring_tickets SET recurring_ticket_next_run = '$next_run' WHERE recurring_ticket_id = $recurring_ticket_id");
}
return $ticket_id;
}
/**
* For post handlers: the recurring ticket a request names, after the module
* and client checks - or back with an error if it is gone. Sets the global
* $client_id.
*/
function recurringTicketForRequest($recurring_ticket_id, $level) {
global $client_id;
enforceUserPermission('module_support', $level);
$row = recurringTicketRow($recurring_ticket_id);
if (!$row) {
flashAlert('That recurring ticket no longer exists.', 'error');
redirect();
}
$client_id = intval($row['recurring_ticket_client_id']);
if ($client_id) {
enforceClientAccess();
}
return $row;
}
/** For post handlers: the recurring tickets a bulk action names (recurring_ticket_ids[]) - [id => row]. */
function recurringTicketsForBulk($level) {
global $client_id;
enforceUserPermission('module_support', $level);
$rows = [];
foreach (array_unique(array_map('intval', (array) ($_POST['recurring_ticket_ids'] ?? []))) as $recurring_ticket_id) {
$row = recurringTicketRow($recurring_ticket_id);
if (!$row) {
continue;
}
$client_id = intval($row['recurring_ticket_client_id']);
if ($client_id) {
enforceClientAccess();
}
$rows[$recurring_ticket_id] = $row;
}
return $rows;
}
/** A date posted from a date field as Y-m-d, or '' when it isn't one. */
function recurringTicketDate($input) {
$input = trim((string) $input);
$date = DateTime::createFromFormat('!Y-m-d', $input);
return $date && $date->format('Y-m-d') === $input && $input <= '2999-12-31' ? $input : '';
}
/** The next few runs from the next one - for showing what a schedule means. */
function recurringTicketUpcoming($row, $count = 3) {
$runs = [];
$date = substr((string) $row['recurring_ticket_next_run'], 0, 10);
if (!recurringTicketDate($date)) {
return $runs;
}
$runs[] = $date;
while (count($runs) < $count) {
$date = recurringTicketNextRun($date, $row['recurring_ticket_frequency'], $row['recurring_ticket_start_date'], $date);
$runs[] = $date;
}
return $runs;
}
/** The last ticket each of these recurring tickets raised, with its status - [recurring id => row]. */
function recurringTicketLastTickets(array $recurring_ticket_ids) {
global $mysqli;
$ids = array_values(array_unique(array_filter(array_map('intval', $recurring_ticket_ids))));
if (!$ids) {
return [];
}
$last = [];
$sql = mysqli_query($mysqli, "SELECT ticket_recurring_ticket_id, MAX(ticket_id) FROM tickets WHERE ticket_recurring_ticket_id IN (" . implode(',', $ids) . ") GROUP BY ticket_recurring_ticket_id");
while ($row = mysqli_fetch_row($sql)) {
$last[intval($row[1])] = intval($row[0]);
}
if (!$last) {
return [];
}
$rows = [];
foreach (ticketRows('tickets.ticket_id IN (' . implode(',', array_keys($last)) . ')') as $row) {
$rows[$last[intval($row['ticket_id'])]] = $row;
}
return $rows;
}
/*
* ---------- scheduled work ----------
* Booked from the ticket, or by dragging it on the calendar - both come here.
*/
/** Work out when a datetime-local value is - 'Y-m-d H:i:s', or '' when it isn't a date and time. */
function ticketScheduleValue($input) {
$sql = ticketDueSql($input);
return $sql === 'NULL' ? '' : trim($sql, "'");
}
/*
* The scheduled-work emails, each with the calendar file - the invite, or its
* cancellation: to the agent doing the work, and when client notifications
* are on (Admin > Ticket) to the contact and the watchers. $row has the
* ticket's id and, for a booking, location_address.
*/
function ticketMailSchedule($row, $cal_str, $cancelled, $when_text) {
global $config_ticket_client_general_notifications;
$ticket = ticketRow($row['ticket_id']);
if (!$ticket || !emailReady()) {
return;
}
$reference = ticketReference($ticket);
$opts = ['cancelled' => $cancelled, 'when' => $when_text, 'where' => (string) ($row['location_address'] ?? '')];
$subject = ($cancelled ? 'Ticket Schedule Cancelled' : 'Ticket Scheduled') . " - [$reference] - " . $ticket['ticket_subject'];
if ($agent = ticketMailAgent($ticket['ticket_assigned_to'])) {
emailQueue([$agent + ['footnote' => "You get this as the agent $reference is assigned to."]], $subject,
ticketAgentEmailContent($ticket, 'scheduled', $opts), ['from' => ticketEmailFrom(), 'cal_str' => $cal_str]);
}
if (intval($config_ticket_client_general_notifications) === 1) {
ticketMailSend($ticket['ticket_id'], $subject, ticketEmailContent($ticket, 'scheduled', $opts), ['cal_str' => $cal_str]);
}
}
/** What the booking emails and the calendar file need of a ticket - the site is the ticket's location, else its contact's. */
function ticketScheduleRow($ticket_id) {
global $mysqli;
return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT tickets.ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_details, ticket_client_id, client_name,
contact_name, contact_email, user_name, user_email, location_address
FROM tickets
LEFT JOIN clients ON ticket_client_id = client_id
LEFT JOIN contacts ON ticket_contact_id = contact_id
LEFT JOIN locations ON location_id = IF(ticket_location_id > 0, ticket_location_id, contact_location_id)
LEFT JOIN users ON ticket_assigned_to = user_id
WHERE tickets.ticket_id = " . intval($ticket_id))) ?: [];
}
/*
* The calendar file for the work booked on a ticket (ticketScheduleRow()) at
* $schedule - the one the booking emails carry, and Add to calendar on the
* guest ticket page. The UID is the ticket's, so either one updates the same
* event in the calendar.
*/
function ticketScheduleIcs(array $row, $schedule) {
global $config_base_url;
$ticket_id = intval($row['ticket_id'] ?? 0);
$subject = intval($row['ticket_number'] ?? 0) . ": " . ($row['client_name'] ?? '') . " - " . ($row['ticket_subject'] ?? '');
$description = mb_substr(strip_tags((string) ($row['ticket_details'] ?? '')), 0, 100) . " - https://$config_base_url/client/ticket.php?id=$ticket_id";
return createiCalStr($schedule, $subject, $description, (string) ($row['location_address'] ?? ''), getTicketCalendarUid($ticket_id));
}
/**
* Book the work on a ticket for $schedule ('Y-m-d H:i:s'), onsite or remote:
* the agent, the contact and the watchers are emailed with the calendar file,
* and it is noted on the ticket. Returns ['when_text' => ..., 'conflicts' =>
* the same agent's other open work within two hours, as plain text].
*/
function ticketSetSchedule(array $row, $schedule, $onsite) {
global $mysqli, $session_user_id, $session_name;
$ticket_id = intval($row['ticket_id']);
$client_id = intval($row['ticket_client_id']);
$onsite = $onsite ? 1 : 0;
$schedule = escapeSql($schedule);
mysqli_query($mysqli, "UPDATE tickets SET ticket_schedule = '$schedule', ticket_onsite = $onsite WHERE ticket_id = $ticket_id");
// The same agent's other open tickets within two hours of it
$conflicts = [];
$assigned_to = intval($row['ticket_assigned_to']);
if ($assigned_to) {
$start = date('Y-m-d H:i:s', strtotime($schedule) - 7200);
$end = date('Y-m-d H:i:s', strtotime($schedule) + 7200);
$sql = mysqli_query($mysqli, "SELECT ticket_prefix, ticket_number, ticket_schedule, ticket_subject FROM tickets
WHERE ticket_schedule BETWEEN '$start' AND '$end' AND ticket_id <> $ticket_id AND ticket_assigned_to = $assigned_to
AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL ORDER BY ticket_schedule");
while ($conflict = mysqli_fetch_assoc($sql)) {
$conflicts[] = ticketReference($conflict) . ' - ' . $conflict['ticket_subject'] . ' @ ' . date('g:ia', strtotime($conflict['ticket_schedule']));
}
}
// Who to tell, with the calendar file
$mail_row = ticketScheduleRow($ticket_id);
$cal_str = ticketScheduleIcs($mail_row, $schedule);
$when_text = date('l, F j, Y \a\t g:ia', strtotime($schedule));
ticketMailSchedule($mail_row, $cal_str, false, $when_text);
// Note it on the ticket
$note = escapeSql("Ticket scheduled for $when_text " . ($onsite ? '(onsite).' : '(remote).'));
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$note', ticket_reply_type = 'Internal', ticket_reply_time_worked = '00:00:00', ticket_reply_by = " . intval($session_user_id) . ", ticket_reply_ticket_id = $ticket_id");
logTicketHistory($ticket_id, "$session_name scheduled the work for " . escapeSql($when_text) . ($onsite ? ' (onsite)' : ' (remote)'));
logAudit("Ticket", "Edit", "$session_name scheduled ticket " . escapeSql(ticketReference($row)) . " for " . escapeSql($when_text), $client_id, $ticket_id);
triggerCustomAction('ticket_schedule', $ticket_id);
return ['when_text' => $when_text, 'conflicts' => $conflicts];
}
/** Take the scheduled work off a ticket, cancelling the calendar file everyone was sent. */
function ticketCancelSchedule(array $row) {
global $mysqli, $session_user_id, $session_name;
$ticket_id = intval($row['ticket_id']);
$client_id = intval($row['ticket_client_id']);
$old_schedule = $row['ticket_schedule'];
mysqli_query($mysqli, "UPDATE tickets SET ticket_schedule = NULL WHERE ticket_id = $ticket_id");
$mail_row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT tickets.ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_client_id, client_name,
contact_name, contact_email, user_name, user_email
FROM tickets
LEFT JOIN clients ON ticket_client_id = client_id
LEFT JOIN contacts ON ticket_contact_id = contact_id
LEFT JOIN users ON ticket_assigned_to = user_id
WHERE tickets.ticket_id = $ticket_id"));
$cal_subject = intval($mail_row['ticket_number']) . ": " . $mail_row['client_name'] . " - " . $mail_row['ticket_subject'];
$cal_str = createiCalStrCancel($old_schedule, $cal_subject, getTicketCalendarUid($ticket_id));
ticketMailSchedule($mail_row, $cal_str, true, '');
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = 'Ticket schedule cancelled.', ticket_reply_type = 'Internal', ticket_reply_time_worked = '00:00:00', ticket_reply_by = " . intval($session_user_id) . ", ticket_reply_ticket_id = $ticket_id");
logTicketHistory($ticket_id, "$session_name cancelled the scheduled work");
logAudit("Ticket", "Edit", "$session_name cancelled the schedule of ticket " . escapeSql(ticketReference($row)), $client_id, $ticket_id);
triggerCustomAction('ticket_unschedule', $ticket_id);
}