Files
itflow/functions/ticket.php
johnnyq 3ec52974db Calendar modernization: calendar page rebuilt to load only the dates on screen with show or hide toggles for each calendar and for built-in sources (scheduled tickets, project start and due dates, service reviews, recurring tickets, renewals, invoices due), a filter for one agent, quick looks on click, drag to reschedule tickets and events that do not repeat, and a To schedule panel for dragging open unscheduled tickets onto the calendar; events get a shared add/edit form with repeats every N days/weeks/months/years with an optional end date and single skipped occurrences, a client site, location or meeting link, agents, client contacts and guest emails as attendees, iCalendar invitations with updates and cancellations sent to invited attendees, an event quick look with map and join links, .ics download and resend; each agent gets a personal subscription link for their schedule (their own or the whole team, busy only option, respects role and client access, stops when the account is disabled) with setup help for Google, Outlook, Apple, Nextcloud and Thunderbird; shared calendar feeds and invites now publish timed events with TZID and VTIMEZONE so repeating events keep their time across DST, with INTERVAL, UNTIL and EXDATE support; managing calendars is limited to admins; ticket schedule logic moved into functions/ticket.php and shared with the calendar, the schedule dialog shows the assigned agent's day, and ticket visit calendar files are rebuilt without zapcal as one-hour published events; mail queue sends invitations and cancellations as a text/calendar part and other calendar files as invite.ics; dashboard schedule shows only events the agent is on or added, client overview Coming up and client sidebar count use upcoming events, sites and contacts get New event shortcuts, demo data uses repeating events and attendees; database update 2.8.8 adds event repeat interval, until, skip, sequence and created by, attendee user and invited at, user_calendar_feeds, and widens email_queue.email_cal_str to text
2026-10-15 19:17:17 -04:00

1246 lines
63 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");
mysqli_query($mysqli, "DELETE FROM tickets WHERE 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.
*/
function ticketMailContactAndWatchers($ticket_id, $subject, $body, $attachments = [], $to_contact = true, $watcher_note = 'YOU ARE A COLLABORATOR ON THIS TICKET', $to_watchers = true) {
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 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)) {
$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 . "<br><br>----------------------------------------<br>$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];
}
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 = '<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", "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 !== '' ? "<br><br>--------------------------------<br>$note<br>--------------------------------" : '';
$body = "<i style=\'color: #808080\'>##- Please type your reply above this line -##</i><br><br>Hello $contact_name,<br><br>Your ticket regarding $subject has been marked as solved and is pending closure.$note_html<br><br>If your request/issue is resolved, you can simply ignore this email. If you need further assistance, please reply or <a href=\'https://$base_url/guest/guest_view_ticket.php?ticket_id=$ticket_id&url_key=$url_key\'>re-open</a> to let us know! <br><br>Ticket: $reference<br>Subject: $subject<br>Status: $status<br>Portal: <a href=\'https://$base_url/guest/guest_view_ticket.php?ticket_id=$ticket_id&url_key=$url_key\'>View ticket</a><br><br>--<br>$company_name - Support<br>$from_email<br>$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 '<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>' : '');
}
/**
* For the client portal: the ticket's status as a badge, and under it
* "Waiting on your reply" when we wrote last on an open ticket. Classes only -
* the portal's content security policy turns inline styles off, so the
* status colours the agents see aren't used here.
*/
function ticketPortalStatusHtml($row) {
$closed = !empty($row['ticket_closed_at']);
$resolved = !$closed && !empty($row['ticket_resolved_at']);
$class = $closed ? 'text-bg-secondary' : ($resolved ? 'text-bg-success' : 'text-bg-primary');
$html = '<span class="badge rounded-pill ' . $class . '">' . escapeHtml($row['ticket_status_name'] ?? '') . '</span>';
if (!$closed && !$resolved && ($row['ticket_last_spoke'] ?? null) === 'Public') {
$html .= '<div class="small text-warning-emphasis mt-1"><i class="fas fa-reply me-1"></i>Waiting on your reply</div>';
}
return $html;
}
/** 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_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 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)) {
$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' => "<i style=\'color: #808080\'>##- Please type your reply above this line -##</i><br><br>Hello $contact_name,<br><br>A ticket regarding \"$subject\" has been automatically created for you.<br><br>--------------------------------<br>$details--------------------------------<br><br>Ticket: $prefix$ticket_number<br>Subject: $subject<br>Status: $status_name<br>Portal: https://$base_url/client/ticket.php?id=$ticket_id$sla_notice<br><br>--<br>$company_name - Support<br>$from_email<br>$company_phone",
];
}
if (filter_var($config_ticket_new_ticket_notification_email, FILTER_VALIDATE_EMAIL)) {
$client_uri = $client_id ? "&client_id=$client_id" : '';
$data[] = [
'from' => $from_email,
'from_name' => $from_name,
'recipient' => escapeSql($config_ticket_new_ticket_notification_email),
'recipient_name' => $from_name,
'subject' => "ITFlow - New Recurring Ticket - $client_name: $subject",
'body' => "Hello, <br><br>This is a notification that a recurring (scheduled) ticket has been raised in ITFlow. <br>Ticket: $prefix$ticket_number<br>Client: $client_name<br>Priority: $priority<br>Link: https://$base_url/agent/ticket.php?ticket_id=$ticket_id$client_uri <br><br>--------------------------------<br><br><b>$subject</b><br>$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 = "<div class='footer'>~<br>" . $html($company_name) . "<br>Support Department<br>" . $html($from_email) . "<br></div><div class='no-reply'>This is an automated message. Please do not reply directly to this email.</div>";
$from = ['from' => $sql($from_email), 'from_name' => $sql($config_ticket_from_name), 'subject' => $mail_subject, 'cal_str' => $cal_str];
$data = [];
// The agent doing the work
if (filter_var($row['user_email'] ?? '', FILTER_VALIDATE_EMAIL)) {
$data[] = $from + ['recipient' => $sql($row['user_email']), 'recipient_name' => $sql($row['user_name']),
'body' => $sql('Hello, ' . $html($row['user_name']) . "<br><br>$what<br><br>--------------------------------<br><a href=\"$agent_link\">" . $html($reference) . "</a><br>--------------------------------<br><br>Please do not reply to this email. <br><br>Ticket: " . $html($reference) . "<br>Subject: " . $html($subject) . "<br>Portal: $agent_link<br><br>~<br>" . $html($company_name) . "<br>Support Department<br>" . $html($from_email))];
}
if (intval($config_ticket_client_general_notifications) === 1) {
// The contact
if (filter_var($row['contact_email'] ?? '', FILTER_VALIDATE_EMAIL)) {
$data[] = $from + ['recipient' => $sql($row['contact_email']), 'recipient_name' => $sql($row['contact_name']),
'body' => $sql("<div class='header'>Hello, " . $html($row['contact_name']) . "</div>$what_yours<br><br><a href='$client_link' class='link-button'>Access your ticket here</a><br><br>Please do not reply to this email.<br><br><strong>Ticket:</strong> " . $html($reference) . "<br><strong>Subject:</strong> " . $html($subject) . "<br><br><br>$footer")];
}
// And the watchers
$watchers = mysqli_query($mysqli, "SELECT watcher_email FROM ticket_watchers WHERE watcher_ticket_id = $ticket_id");
foreach (ticketWatcherEmails(array_column(mysqli_fetch_all($watchers, MYSQLI_ASSOC), 'watcher_email')) as $watcher_email) {
if (strcasecmp($watcher_email, (string) ($row['contact_email'] ?? '')) === 0) {
continue;
}
$data[] = $from + ['recipient' => $sql($watcher_email), 'recipient_name' => $sql($watcher_email),
'body' => $sql("<div class='header'>Hello,</div>$what<br><br><a href='$client_link' class='link-button'>" . $html($reference) . "</a><br><br>Please do not reply to this email.<br><br><strong>Ticket:</strong> " . $html($reference) . "<br><strong>Subject:</strong> " . $html($subject) . "<br><strong>Portal:</strong> <a href='$client_link'>Access the ticket here</a><br><br>$footer")];
}
}
if ($data) {
addToMailQueue($data);
}
}
/**
* 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, $config_base_url, $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 = 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 = $ticket_id"));
$cal_subject = intval($mail_row['ticket_number']) . ": " . $mail_row['client_name'] . " - " . $mail_row['ticket_subject'];
$cal_description = mb_substr(strip_tags((string) $mail_row['ticket_details']), 0, 100) . " - https://$config_base_url/client/ticket.php?id=$ticket_id";
$cal_str = createiCalStr($schedule, $cal_subject, $cal_description, (string) $mail_row['location_address'], getTicketCalendarUid($ticket_id));
$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);
}