['days' => 3],
'Weekly' => ['days' => 7],
'Biweekly' => ['days' => 14],
'Monthly' => ['months' => 1],
'Quarterly' => ['months' => 3],
'Biannually' => ['months' => 6],
'Annually' => ['months' => 12],
];
/** May this agent do this with tickets? 1 is to see them, 2 to work them, 3 to delete them. */
function ticketCan($level) {
return lookupUserPermission('module_support') >= $level;
}
/** One ticket by id, with its client, contact, status and agent - raw, or null. */
function ticketRow($ticket_id) {
global $mysqli;
$ticket_id = intval($ticket_id);
if (!$ticket_id) {
return null;
}
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT tickets.*, client_name, contact_name, contact_email,
ticket_status_name, ticket_status_color, user_name, user_avatar
FROM tickets
LEFT JOIN clients ON client_id = ticket_client_id
LEFT JOIN contacts ON contact_id = ticket_contact_id
LEFT JOIN ticket_statuses ON ticket_status_id = ticket_status
LEFT JOIN users ON user_id = ticket_assigned_to
WHERE ticket_id = $ticket_id LIMIT 1"));
return $row ?: null;
}
/**
* For post handlers: the ticket a request names, after the module and client
* checks (a ticket with no client is internal work any agent may reach) - or
* back with an error if it is gone. Sets the global $client_id.
*/
function ticketForRequest($ticket_id, $level) {
global $client_id;
enforceUserPermission('module_support', $level);
$row = ticketRow($ticket_id);
if (!$row) {
flashAlert('That ticket no longer exists.', 'error');
redirect();
}
$client_id = intval($row['ticket_client_id']);
if ($client_id) {
enforceClientAccess();
}
return $row;
}
/**
* For modals: the ticket a modal is opened for, after the module and client
* checks - or the modal's error, and nothing more. $open_only refuses a
* closed ticket. Sets the global $client_id.
*/
function ticketForModal($ticket_id, $level = 2, $open_only = false) {
global $client_id;
enforceUserPermission('module_support', $level);
$row = ticketRow($ticket_id);
if (!$row) {
echo json_encode(['error' => 'That ticket no longer exists.']);
exit;
}
$client_id = intval($row['ticket_client_id']);
if ($client_id) {
enforceClientAccess();
}
if ($open_only && !empty($row['ticket_closed_at'])) {
echo json_encode(['error' => 'Ticket ' . ticketReference($row) . ' is closed.']);
exit;
}
return $row;
}
/** For post handlers: the tickets a bulk action names (ticket_ids[]) that this agent may act on - [id => row]. */
function ticketsForBulk($level) {
global $client_id;
enforceUserPermission('module_support', $level);
$rows = [];
foreach (array_unique(array_map('intval', (array) ($_POST['ticket_ids'] ?? []))) as $ticket_id) {
$row = ticketRow($ticket_id);
if (!$row) {
continue;
}
$client_id = intval($row['ticket_client_id']);
if ($client_id) {
enforceClientAccess();
}
$rows[$ticket_id] = $row;
}
return $rows;
}
/** Its reference - TCK-1234. */
function ticketReference($row) {
return ($row['ticket_prefix'] ?? '') . intval($row['ticket_number'] ?? 0);
}
/** Is it still being worked - neither resolved nor closed? */
function ticketIsOpen($row) {
return empty($row['ticket_resolved_at']) && empty($row['ticket_closed_at']);
}
/** Where the ticket page is - inside its client when it has one. */
function ticketUrl($ticket_id, $client_id = 0) {
$client_id = intval($client_id);
return 'ticket.php?' . ($client_id ? "client_id=$client_id&" : '') . 'ticket_id=' . intval($ticket_id);
}
/** A ticket wherever a page mentions one: its reference, then its subject, both opening the ticket. 'subject' => false for the reference alone. */
function ticketLink($row, $options = []) {
$url = escapeHtml(ticketUrl($row['ticket_id'], $row['ticket_client_id'] ?? 0));
$reference = escapeHtml(ticketReference($row));
$html = '' . $reference . '';
if (($options['subject'] ?? true) && isset($row['ticket_subject'])) {
$html .= ' ' . escapeHtml($row['ticket_subject']) . '';
}
return $html;
}
/**
* A record a ticket of this client may link to - its id when it is the
* client's own, else 0. $kind is contact, asset, location, vendor or
* project. A project may also be one with no client (internal work); a
* ticket with no client may only link to our own vendors and projects.
*/
function ticketClientOwnedId($kind, $id, $client_id) {
global $mysqli;
$id = intval($id);
$client_id = intval($client_id);
if (!$id) {
return 0;
}
if ($kind === 'project') {
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT project_id FROM projects WHERE project_id = $id AND project_client_id IN (0, $client_id) LIMIT 1"));
return $row ? $id : 0;
}
if (!$client_id && $kind !== 'vendor') {
return 0;
}
return assetClientOwnedId($kind, $id, $client_id);
}
/** The client's own assets out of a list of ids, without the primary one - for "other assets". */
function ticketClientOwnedAssetIds($ids, $client_id, $primary_asset_id = 0) {
global $mysqli;
$ids = array_values(array_diff(array_unique(array_filter(array_map('intval', (array) $ids))), [intval($primary_asset_id)]));
$client_id = intval($client_id);
if (!$ids || !$client_id) {
return [];
}
$owned = [];
$sql = mysqli_query($mysqli, "SELECT asset_id FROM assets WHERE asset_client_id = $client_id AND asset_id IN (" . implode(',', $ids) . ")");
while ($row = mysqli_fetch_row($sql)) {
$owned[] = intval($row[0]);
}
return $owned;
}
/** Replace a ticket's other assets with these (already checked) ids. */
function ticketSetOtherAssets($ticket_id, array $asset_ids) {
global $mysqli;
$ticket_id = intval($ticket_id);
mysqli_query($mysqli, "DELETE FROM ticket_assets WHERE ticket_id = $ticket_id");
foreach ($asset_ids as $asset_id) {
mysqli_query($mysqli, "INSERT IGNORE INTO ticket_assets SET ticket_id = $ticket_id, asset_id = " . intval($asset_id));
}
}
/** An active agent's id, else 0 (unassigned). */
function ticketAgentId($user_id) {
global $mysqli;
$user_id = intval($user_id);
if (!$user_id) {
return 0;
}
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT user_id FROM users WHERE user_id = $user_id AND user_type = 1 AND user_status = 1 AND user_archived_at IS NULL LIMIT 1"));
return $row ? $user_id : 0;
}
/** A ticket category's id, else 0 (not categorised). */
function ticketCategoryId($category_id) {
global $mysqli;
$category_id = intval($category_id);
if (!$category_id) {
return 0;
}
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT category_id FROM categories WHERE category_id = $category_id AND category_type = 'Ticket' LIMIT 1"));
return $row ? $category_id : 0;
}
/** A priority as posted, or Medium when it isn't one. */
function ticketPriority($priority) {
return in_array($priority, TICKET_PRIORITIES, true) ? $priority : 'Medium';
}
/** A due date posted from a datetime-local field (with or without seconds) as SQL - quoted, or NULL. */
function ticketDueSql($input) {
$input = trim((string) $input);
if ($input === '') {
return 'NULL';
}
foreach (['Y-m-d\TH:i', 'Y-m-d\TH:i:s', 'Y-m-d H:i:s', 'Y-m-d H:i'] as $format) {
$date = DateTime::createFromFormat("!$format", $input);
if ($date && $date->format($format) === $input) {
return "'" . $date->format('Y-m-d H:i:s') . "'";
}
}
return 'NULL';
}
/** The watchers posted - valid email addresses only, each once, lower-cased. */
function ticketWatcherEmails($input) {
$list = is_array($input) ? $input : preg_split('/[\s,;]+/', (string) $input);
$emails = [];
foreach ($list as $email) {
$email = strtolower(trim((string) $email));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$emails[$email] = true;
}
}
return array_keys($emails);
}
/** Add watchers a ticket doesn't have already - the ones added. */
function ticketAddWatchers($ticket_id, array $emails) {
global $mysqli;
$ticket_id = intval($ticket_id);
$added = [];
foreach ($emails as $email) {
$email_sql = escapeSql($email);
if (mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM ticket_watchers WHERE watcher_ticket_id = $ticket_id AND watcher_email = '$email_sql' LIMIT 1"))) {
continue;
}
mysqli_query($mysqli, "INSERT INTO ticket_watchers SET watcher_email = '$email_sql', watcher_ticket_id = $ticket_id");
$added[] = $email;
}
return $added;
}
/**
* Delete a ticket and everything that is only its: replies, views, watchers,
* attachments (and their files), tasks and their approvals, history and SLA
* history. Its other-asset links go with it (ON DELETE CASCADE).
*/
function ticketDeleteForGood($ticket_id) {
global $mysqli;
$ticket_id = intval($ticket_id);
if (!$ticket_id) {
return;
}
mysqli_query($mysqli, "DELETE FROM task_approvals WHERE approval_task_id IN (SELECT task_id FROM tasks WHERE task_ticket_id = $ticket_id)");
mysqli_query($mysqli, "DELETE FROM tasks WHERE task_ticket_id = $ticket_id");
mysqli_query($mysqli, "DELETE FROM ticket_replies WHERE ticket_reply_ticket_id = $ticket_id");
mysqli_query($mysqli, "DELETE FROM ticket_views WHERE view_ticket_id = $ticket_id");
mysqli_query($mysqli, "DELETE FROM ticket_watchers WHERE watcher_ticket_id = $ticket_id");
mysqli_query($mysqli, "DELETE FROM ticket_attachments WHERE ticket_attachment_ticket_id = $ticket_id");
mysqli_query($mysqli, "DELETE FROM ticket_history WHERE ticket_history_ticket_id = $ticket_id");
mysqli_query($mysqli, "DELETE FROM sla_history WHERE sla_history_ticket_id = $ticket_id");
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");
}
/** The company's name and phone, SQL-escaped, for the foot of a ticket email. */
function ticketMailCompany() {
global $mysqli;
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT company_name, company_phone, company_phone_country_code FROM companies WHERE company_id = 1"));
return [escapeSql($row['company_name'] ?? ''), escapeSql(formatPhoneNumber($row['company_phone'] ?? '', $row['company_phone_country_code'] ?? ''))];
}
/**
* Queue an email about a ticket to its contact (when $to_contact and the
* address is valid) and to every watcher, who get $watcher_note under it.
* $subject and $body are already SQL-escaped, as addToMailQueue() takes
* them. Nothing is queued without a mail provider. How many were queued.
* $to_watchers false is the contact alone - and leaves out the colleagues.
*
* $kind is what the email is: 'created', 'closed', or 'update' (a reply,
* a fix, anything else). 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()).
*/
function ticketMailContactAndWatchers($ticket_id, $subject, $body, $attachments = [], $to_contact = true, $watcher_note = 'YOU ARE A COLLABORATOR ON THIS TICKET', $to_watchers = true, $kind = 'update') {
global $mysqli, $config_smtp_provider, $config_ticket_from_email, $config_ticket_from_name;
if (empty($config_smtp_provider)) {
return 0;
}
$ticket_id = intval($ticket_id);
$from_email = escapeSql($config_ticket_from_email);
$from_name = escapeSql($config_ticket_from_name);
$data = [];
$sent_to = [];
if ($to_contact) {
$contact = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_name, contact_email, contact_ticket_emails FROM tickets JOIN contacts ON contact_id = ticket_contact_id WHERE ticket_id = $ticket_id"));
if ($contact && filter_var($contact['contact_email'], FILTER_VALIDATE_EMAIL) && ticketContactWantsEmail($contact['contact_ticket_emails'], $kind)) {
$data[] = ['from' => $from_email, 'from_name' => $from_name, 'recipient' => escapeSql($contact['contact_email']), 'recipient_name' => escapeSql($contact['contact_name']),
'subject' => $subject, 'body' => $body, 'attachments' => $attachments];
$sent_to[strtolower($contact['contact_email'])] = true;
}
}
$watcher_body = $body . "
---------------------------------------- $watcher_note";
$sql = mysqli_query($mysqli, "SELECT watcher_email FROM ticket_watchers WHERE watcher_ticket_id = $ticket_id" . ($to_watchers ? '' : ' AND 0'));
while ($row = mysqli_fetch_assoc($sql)) {
$email = strtolower(trim($row['watcher_email']));
if (isset($sent_to[$email]) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
continue;
}
$sent_to[$email] = true;
$data[] = ['from' => $from_email, 'from_name' => $from_name, 'recipient' => escapeSql($email), 'recipient_name' => escapeSql($email),
'subject' => $subject, 'body' => $watcher_body, 'attachments' => $attachments];
}
// A new ticket reaches them by ticketMailColleaguesNew(), in an email of its own
if ($to_watchers && $kind !== 'created') {
foreach (ticketColleagueRecipients($ticket_id, $kind) as $email => $colleague) {
if (isset($sent_to[strtolower($email)])) {
continue;
}
$sent_to[strtolower($email)] = true;
$note = escapeSql("You get this because you asked for every update on " . $colleague['client_name'] . "'s tickets - change that under Your account in the client portal.");
$data[] = ['from' => $from_email, 'from_name' => $from_name, 'recipient' => escapeSql($email), 'recipient_name' => escapeSql($colleague['name']),
'subject' => $subject, 'body' => $body . "
---------------------------------------- $note", 'attachments' => $attachments];
}
}
if ($data) {
addToMailQueue($data);
}
return count($data);
}
/** 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 $mysqli, $config_smtp_provider, $config_ticket_client_general_notifications, $config_ticket_from_email, $config_ticket_from_name, $config_base_url;
if (empty($config_smtp_provider) || 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';
$data = [];
foreach (ticketColleagueRecipients($ticket_id, 'created') as $email => $colleague) {
$url = $colleague['portal']
? "https://$config_base_url/client/ticket.php?id=$ticket_id"
: "https://$config_base_url/guest/guest_view_ticket.php?ticket_id=$ticket_id&url_key=" . rawurlencode((string) $ticket['ticket_url_key']);
$html = billingEmailHtml([
'kind' => 'Support', 'tone' => 'primary', 'name' => $colleague['name'],
'title' => "New ticket at " . $colleague['client_name'],
'intro' => ["$raised_by raised a ticket at " . $colleague['client_name'] . '.'],
'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],
'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.' : '.'),
]);
$data[] = [
'from' => mysqli_real_escape_string($mysqli, (string) $config_ticket_from_email),
'from_name' => mysqli_real_escape_string($mysqli, (string) $config_ticket_from_name),
'recipient' => mysqli_real_escape_string($mysqli, $email),
'recipient_name' => mysqli_real_escape_string($mysqli, $colleague['name']),
'subject' => mysqli_real_escape_string($mysqli, "New ticket - [$reference] - " . $ticket['ticket_subject']),
'body' => mysqli_real_escape_string($mysqli, $html),
];
}
if ($data) {
addToMailQueue($data);
}
return count($data);
}
/** 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 = '
';
}
/**
* 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", "Resolved", "$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", "Reopened", "$session_name reopened ticket " . escapeSql(ticketReference($row)), intval($row['ticket_client_id']), $ticket_id);
triggerCustomAction('ticket_update', $ticket_id);
}
/** The "your ticket is resolved, pending closure" email - to its contact and watchers. $note is the agent's words, SQL-escaped, or ''. */
function ticketMailResolved($ticket_id, $note = '') {
global $config_base_url, $config_ticket_from_email;
$row = ticketRow($ticket_id);
if (!$row) {
return 0;
}
[$company_name, $company_phone] = ticketMailCompany();
$ticket_id = intval($ticket_id);
$contact_name = escapeSql($row['contact_name']);
$reference = escapeSql(ticketReference($row));
$subject = escapeSql($row['ticket_subject']);
$status = escapeSql($row['ticket_status_name']);
$url_key = escapeSql($row['ticket_url_key']);
$base_url = escapeSql($config_base_url);
$from_email = escapeSql($config_ticket_from_email);
$note_html = $note !== '' ? "
-------------------------------- $note --------------------------------" : '';
$body = "##- Please type your reply above this line -##
Hello $contact_name,
Your ticket regarding $subject has been marked as solved and is pending closure.$note_html
If your request/issue is resolved, you can simply ignore this email. If you need further assistance, please reply or re-open to let us know!
-- $company_name - Support $from_email $company_phone";
return ticketMailContactAndWatchers($ticket_id, "Ticket resolved - [$reference] - $subject | (pending closure)", $body);
}
/*
* ---------- 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 'Breached';
case 'at_risk':
return '' . (!empty($sla['due']) ? timeAgo($sla['due']) : 'At risk') . '';
case 'paused':
return 'Paused';
case 'running':
return '' . timeAgo($sla['due']) . '';
case 'met':
return 'SLA met';
case 'missed':
return 'SLA missed';
}
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 'Due ' . timeAgo($due['at']) . '';
}
return 'Due ' . timeAgo($due['at']) . '';
}
/** 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 ''
. ''
. "$done/$all";
}
/**
* 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 'No replies';
}
$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 ''
. '' . timeAgo($reply['ticket_reply_created_at']) . ''
. ($who !== '' ? '
' . escapeHtml($who) . '
' : '');
}
/** 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 '';
}
}
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 ''
. '' . escapeHtml($state['text']) . '';
}
/**
* 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_base_url;
$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);
logAudit("Ticket", "Create", escapeSql($by) . " created recurring $frequency ticket $prefix$ticket_number - $subject", $client_id, $ticket_id);
triggerCustomAction('ticket_create', $ticket_id);
// Who to tell
$ticket = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT client_name, contact_name, contact_email, contact_ticket_emails FROM tickets
LEFT JOIN clients ON ticket_client_id = client_id
LEFT JOIN contacts ON ticket_contact_id = contact_id
WHERE ticket_id = $ticket_id"));
$company = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT company_name, company_phone, company_phone_country_code FROM companies WHERE company_id = 1"));
$company_name = escapeSql($company['company_name'] ?? '');
$company_phone = escapeSql(formatPhoneNumber($company['company_phone'] ?? '', $company['company_phone_country_code'] ?? ''));
$contact_name = escapeSql($ticket['contact_name'] ?? '');
$contact_email = escapeSql($ticket['contact_email'] ?? '');
$client_name = escapeSql($ticket['client_name'] ?? '');
$from_email = escapeSql($config_ticket_from_email);
$from_name = escapeSql($config_ticket_from_name);
$base_url = escapeSql($config_base_url);
$data = [];
if (!empty($config_smtp_provider) && intval($config_ticket_client_general_notifications) === 1 && filter_var($contact_email, FILTER_VALIDATE_EMAIL)
&& ticketContactWantsEmail($ticket['contact_ticket_emails'] ?? 'all', 'created')) {
$sla_notice = escapeSql(getTicketSlaEmailNotice($ticket_id, $company_phone));
$status_name = escapeSql(getTicketStatusName($ticket_status));
$data[] = [
'from' => $from_email,
'from_name' => $from_name,
'recipient' => $contact_email,
'recipient_name' => $contact_name,
'subject' => "Ticket created - [$prefix$ticket_number] - $subject (scheduled)",
'body' => "##- Please type your reply above this line -##
Hello $contact_name,
A ticket regarding \"$subject\" has been automatically created for you.
This is a notification that a recurring (scheduled) ticket has been raised in ITFlow. Ticket: $prefix$ticket_number Client: $client_name Priority: $priority Link: https://$base_url/agent/ticket.php?ticket_id=$ticket_id$client_uri
--------------------------------
$subject $details",
];
}
if ($data) {
addToMailQueue($data);
}
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-visit emails - the agent, the contact and the watchers - with the calendar invite (or its cancellation). */
function ticketMailSchedule($row, $cal_str, $cancelled, $when_text) {
global $mysqli, $config_smtp_provider, $config_ticket_from_email, $config_ticket_from_name, $config_base_url, $config_ticket_client_general_notifications, $session_company_name;
if (empty($config_smtp_provider)) {
return;
}
// Built as plain text and HTML, then escaped once for the mail queue
$sql = function ($value) use ($mysqli) {
return mysqli_real_escape_string($mysqli, (string) $value);
};
$html = function ($value) {
return escapeHtml((string) $value);
};
$ticket_id = intval($row['ticket_id']);
$client_id = intval($row['ticket_client_id']);
$reference = ticketReference($row);
$subject = (string) $row['ticket_subject'];
$company_name = (string) $session_company_name;
$from_email = (string) $config_ticket_from_email;
$agent_link = "https://$config_base_url/agent/ticket.php?ticket_id=$ticket_id" . ($client_id ? "&client_id=$client_id" : '');
$client_link = "https://$config_base_url/client/ticket.php?id=$ticket_id";
$what = $cancelled ? 'Scheduled work for the ticket regarding ' . $html($subject) . ' has been cancelled.' : 'The ticket regarding ' . $html($subject) . ' has been scheduled for ' . $html($when_text) . '.';
$what_yours = $cancelled ? 'Scheduled work for your ticket regarding ' . $html($subject) . ' has been cancelled.' : 'Your ticket regarding ' . $html($subject) . ' has been scheduled for ' . $html($when_text) . '.';
$mail_subject = $sql(($cancelled ? 'Ticket Schedule Cancelled' : 'Ticket Scheduled') . " - [$reference] - $subject");
$footer = "
This is an automated message. Please do not reply directly to this email.