['days' => 3], 'Weekly' => ['days' => 7], 'Biweekly' => ['days' => 14], 'Monthly' => ['months' => 1], 'Quarterly' => ['months' => 3], 'Biannually' => ['months' => 6], 'Annually' => ['months' => 12], ]; /** May this agent do this with tickets? 1 is to see them, 2 to work them, 3 to delete them. */ function ticketCan($level) { return lookupUserPermission('module_support') >= $level; } /** One ticket by id, with its client, contact, status and agent - raw, or null. */ function ticketRow($ticket_id) { global $mysqli; $ticket_id = intval($ticket_id); if (!$ticket_id) { return null; } $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT tickets.*, client_name, contact_name, contact_email, ticket_status_name, ticket_status_color, user_name, user_avatar FROM tickets LEFT JOIN clients ON client_id = ticket_client_id LEFT JOIN contacts ON contact_id = ticket_contact_id LEFT JOIN ticket_statuses ON ticket_status_id = ticket_status LEFT JOIN users ON user_id = ticket_assigned_to WHERE ticket_id = $ticket_id LIMIT 1")); return $row ?: null; } /** * For post handlers: the ticket a request names, after the module and client * checks (a ticket with no client is internal work any agent may reach) - or * back with an error if it is gone. Sets the global $client_id. */ function ticketForRequest($ticket_id, $level) { global $client_id; enforceUserPermission('module_support', $level); $row = ticketRow($ticket_id); if (!$row) { flashAlert('That ticket no longer exists.', 'error'); redirect(); } $client_id = intval($row['ticket_client_id']); if ($client_id) { enforceClientAccess(); } return $row; } /** * For modals: the ticket a modal is opened for, after the module and client * checks - or the modal's error, and nothing more. $open_only refuses a * closed ticket. Sets the global $client_id. */ function ticketForModal($ticket_id, $level = 2, $open_only = false) { global $client_id; enforceUserPermission('module_support', $level); $row = ticketRow($ticket_id); if (!$row) { echo json_encode(['error' => 'That ticket no longer exists.']); exit; } $client_id = intval($row['ticket_client_id']); if ($client_id) { enforceClientAccess(); } if ($open_only && !empty($row['ticket_closed_at'])) { echo json_encode(['error' => 'Ticket ' . ticketReference($row) . ' is closed.']); exit; } return $row; } /** For post handlers: the tickets a bulk action names (ticket_ids[]) that this agent may act on - [id => row]. */ function ticketsForBulk($level) { global $client_id; enforceUserPermission('module_support', $level); $rows = []; foreach (array_unique(array_map('intval', (array) ($_POST['ticket_ids'] ?? []))) as $ticket_id) { $row = ticketRow($ticket_id); if (!$row) { continue; } $client_id = intval($row['ticket_client_id']); if ($client_id) { enforceClientAccess(); } $rows[$ticket_id] = $row; } return $rows; } /** Its reference - TCK-1234. */ function ticketReference($row) { return ($row['ticket_prefix'] ?? '') . intval($row['ticket_number'] ?? 0); } /** Is it still being worked - neither resolved nor closed? */ function ticketIsOpen($row) { return empty($row['ticket_resolved_at']) && empty($row['ticket_closed_at']); } /** Where the ticket page is - inside its client when it has one. */ function ticketUrl($ticket_id, $client_id = 0) { $client_id = intval($client_id); return 'ticket.php?' . ($client_id ? "client_id=$client_id&" : '') . 'ticket_id=' . intval($ticket_id); } /** A ticket wherever a page mentions one: its reference, then its subject, both opening the ticket. 'subject' => false for the reference alone. */ function ticketLink($row, $options = []) { $url = escapeHtml(ticketUrl($row['ticket_id'], $row['ticket_client_id'] ?? 0)); $reference = escapeHtml(ticketReference($row)); $html = '' . $reference . ''; if (($options['subject'] ?? true) && isset($row['ticket_subject'])) { $html .= ' ' . escapeHtml($row['ticket_subject']) . ''; } return $html; } /** * A record a ticket of this client may link to - its id when it is the * client's own, else 0. $kind is contact, asset, location, vendor or * project. A project may also be one with no client (internal work); a * ticket with no client may only link to our own vendors and projects. */ function ticketClientOwnedId($kind, $id, $client_id) { global $mysqli; $id = intval($id); $client_id = intval($client_id); if (!$id) { return 0; } if ($kind === 'project') { $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT project_id FROM projects WHERE project_id = $id AND project_client_id IN (0, $client_id) LIMIT 1")); return $row ? $id : 0; } if (!$client_id && $kind !== 'vendor') { return 0; } return assetClientOwnedId($kind, $id, $client_id); } /** The client's own assets out of a list of ids, without the primary one - for "other assets". */ function ticketClientOwnedAssetIds($ids, $client_id, $primary_asset_id = 0) { global $mysqli; $ids = array_values(array_diff(array_unique(array_filter(array_map('intval', (array) $ids))), [intval($primary_asset_id)])); $client_id = intval($client_id); if (!$ids || !$client_id) { return []; } $owned = []; $sql = mysqli_query($mysqli, "SELECT asset_id FROM assets WHERE asset_client_id = $client_id AND asset_id IN (" . implode(',', $ids) . ")"); while ($row = mysqli_fetch_row($sql)) { $owned[] = intval($row[0]); } return $owned; } /** Replace a ticket's other assets with these (already checked) ids. */ function ticketSetOtherAssets($ticket_id, array $asset_ids) { global $mysqli; $ticket_id = intval($ticket_id); mysqli_query($mysqli, "DELETE FROM ticket_assets WHERE ticket_id = $ticket_id"); foreach ($asset_ids as $asset_id) { mysqli_query($mysqli, "INSERT IGNORE INTO ticket_assets SET ticket_id = $ticket_id, asset_id = " . intval($asset_id)); } } /** An active agent's id, else 0 (unassigned). */ function ticketAgentId($user_id) { global $mysqli; $user_id = intval($user_id); if (!$user_id) { return 0; } $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT user_id FROM users WHERE user_id = $user_id AND user_type = 1 AND user_status = 1 AND user_archived_at IS NULL LIMIT 1")); return $row ? $user_id : 0; } /** A ticket category's id, else 0 (not categorised). */ function ticketCategoryId($category_id) { global $mysqli; $category_id = intval($category_id); if (!$category_id) { return 0; } $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT category_id FROM categories WHERE category_id = $category_id AND category_type = 'Ticket' LIMIT 1")); return $row ? $category_id : 0; } /** A priority as posted, or Medium when it isn't one. */ function ticketPriority($priority) { return in_array($priority, TICKET_PRIORITIES, true) ? $priority : 'Medium'; } /** A due date posted from a datetime-local field (with or without seconds) as SQL - quoted, or NULL. */ function ticketDueSql($input) { $input = trim((string) $input); if ($input === '') { return 'NULL'; } foreach (['Y-m-d\TH:i', 'Y-m-d\TH:i:s', 'Y-m-d H:i:s', 'Y-m-d H:i'] as $format) { $date = DateTime::createFromFormat("!$format", $input); if ($date && $date->format($format) === $input) { return "'" . $date->format('Y-m-d H:i:s') . "'"; } } return 'NULL'; } /** The watchers posted - valid email addresses only, each once, lower-cased. */ function ticketWatcherEmails($input) { $list = is_array($input) ? $input : preg_split('/[\s,;]+/', (string) $input); $emails = []; foreach ($list as $email) { $email = strtolower(trim((string) $email)); if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { $emails[$email] = true; } } return array_keys($emails); } /** Add watchers a ticket doesn't have already - the ones added. */ function ticketAddWatchers($ticket_id, array $emails) { global $mysqli; $ticket_id = intval($ticket_id); $added = []; foreach ($emails as $email) { $email_sql = escapeSql($email); if (mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM ticket_watchers WHERE watcher_ticket_id = $ticket_id AND watcher_email = '$email_sql' LIMIT 1"))) { continue; } mysqli_query($mysqli, "INSERT INTO ticket_watchers SET watcher_email = '$email_sql', watcher_ticket_id = $ticket_id"); $added[] = $email; } return $added; } /** * Delete a ticket and everything that is only its: replies, views, watchers, * attachments (and their files), tasks and their approvals, history and SLA * history. Its other-asset links go with it (ON DELETE CASCADE). */ function ticketDeleteForGood($ticket_id) { global $mysqli; $ticket_id = intval($ticket_id); if (!$ticket_id) { return; } mysqli_query($mysqli, "DELETE FROM task_approvals WHERE approval_task_id IN (SELECT task_id FROM tasks WHERE task_ticket_id = $ticket_id)"); mysqli_query($mysqli, "DELETE FROM tasks WHERE task_ticket_id = $ticket_id"); mysqli_query($mysqli, "DELETE FROM ticket_replies WHERE ticket_reply_ticket_id = $ticket_id"); mysqli_query($mysqli, "DELETE FROM ticket_views WHERE view_ticket_id = $ticket_id"); mysqli_query($mysqli, "DELETE FROM ticket_watchers WHERE watcher_ticket_id = $ticket_id"); mysqli_query($mysqli, "DELETE FROM ticket_attachments WHERE ticket_attachment_ticket_id = $ticket_id"); mysqli_query($mysqli, "DELETE FROM ticket_history WHERE ticket_history_ticket_id = $ticket_id"); mysqli_query($mysqli, "DELETE FROM sla_history WHERE sla_history_ticket_id = $ticket_id"); // The prepaid hours it drew go back to their contracts if (function_exists('contractHoursReady') && contractHoursReady()) { mysqli_query($mysqli, "DELETE FROM contract_block_uses WHERE contract_block_use_ticket_id = $ticket_id"); } mysqli_query($mysqli, "DELETE FROM tickets WHERE ticket_id = $ticket_id"); // The driving for it still happened - the trips stay, without the ticket mysqli_query($mysqli, "UPDATE trips SET trip_ticket_id = 0 WHERE trip_ticket_id = $ticket_id"); removeDirectory(__DIR__ . "/../uploads/tickets/$ticket_id"); } /* ------------------------------------------------------------ Emails about a ticket */ /** Who ticket emails come from - the ticket mailbox (Admin > Mail). */ function ticketEmailFrom() { return emailFrom('ticket'); } /** Where a contact or watcher opens a ticket - its guest link, no sign-in needed, or the portal when it has none. */ function ticketEmailUrl(array $row) { global $config_base_url; $ticket_id = intval($row['ticket_id']); return !empty($row['ticket_url_key']) ? "https://$config_base_url/guest/guest_view_ticket.php?ticket_id=$ticket_id&url_key=" . rawurlencode((string) $row['ticket_url_key']) : "https://$config_base_url/client/ticket.php?id=$ticket_id"; } /* * A ticket opened by its guest link - ticketRow() when $url_key is the * ticket's key exactly, else null. The key is all the guest pages have to go * on, so it is compared here, whole and case for case: MySQL's = would let * another case or trailing spaces match. */ function ticketByGuestKey($ticket_id, $url_key) { $row = ticketRow($ticket_id); $key = (string) ($row['ticket_url_key'] ?? ''); return $row && $key !== '' && is_string($url_key) && hash_equals($key, $url_key) ? $row : null; } /** The guest ticket page's address, from a page in guest/. */ function ticketGuestPath(array $row) { return 'guest_view_ticket.php?ticket_id=' . intval($row['ticket_id']) . '&url_key=' . rawurlencode((string) $row['ticket_url_key']); } /** The foot's contact line on a ticket email - the company's phone and the support address. */ function ticketEmailContactLine() { $company = billingCompany(); return implode(' ยท ', array_filter([$company['phone_display'] ?? '', ticketEmailFrom()['email'], $company['company_website'] ?? ''])); } /* * What a new ticket's SLA promises the client - ['summary' => rows to add, * 'callout' => a paragraph]: when they will hear back, and for an Urgent or * High one, to phone instead of waiting. Nothing when no SLA with a response * target applies. Call it after applyTicketSla(), which sets the due time. */ function ticketEmailSla($ticket_id) { global $mysqli; $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_priority, ticket_response_due_at, sla_response_minutes FROM tickets LEFT JOIN slas ON ticket_sla_id = sla_id WHERE ticket_id = " . intval($ticket_id) . " LIMIT 1")); if (!$row || empty($row['ticket_response_due_at']) || intval($row['sla_response_minutes']) <= 0) { return ['summary' => [], 'callout' => '']; } $phone = billingCompany()['phone_display'] ?? ''; $callout = ''; if ($phone !== '' && $row['ticket_priority'] === 'Urgent') { $callout = "This ticket is marked Urgent. If it is stopping work right now, call us on $phone rather than waiting for a reply."; } elseif ($phone !== '' && $row['ticket_priority'] === 'High') { $callout = "This ticket is marked High priority. If it is holding up your business, calling us on $phone gets you the fastest answer."; } return [ 'summary' => ['We will respond' => 'within ' . formatSlaMinutes(intval($row['sla_response_minutes'])) . ', by ' . date('D j M, g:i A', strtotime($row['ticket_response_due_at']))], 'callout' => $callout, ]; } /* * The conversation on a ticket, for the end of an email about it * (emailHtml()'s history): its public replies newest first, and at the end * the request it was opened with - each a short plain-text excerpt * (ticketEmailExcerpt()), so a pasted screenshot or a long signature can't * swell the email. Never an internal note, nor the lines the portal writes * when a contact resolves, reopens or closes. $opts: * label the heading - Earlier on this ticket * skip the HTML of the reply the email itself shows - it, and anything newer, is left out * replies false for the request alone * request false leaves the request out, when the email shows it already * limit how many replies at most (4) - the rest are counted, before the request * [] when there is nothing earlier to show. */ function ticketEmailHistory(array $row, array $opts = []) { global $mysqli; $ticket_id = intval($row['ticket_id']); $limit = max(0, intval($opts['limit'] ?? 4)); $company_name = (string) (billingCompany()['company_name'] ?? ''); $items = []; $left_out = 0; if ($opts['replies'] ?? true) { $sql = mysqli_query($mysqli, "SELECT ticket_reply, ticket_reply_type, ticket_reply_created_at, contact_name, user_name FROM ticket_replies LEFT JOIN users ON ticket_reply_type != 'Client' AND user_id = ticket_reply_by LEFT JOIN contacts ON ticket_reply_type = 'Client' AND contact_id = ticket_reply_by WHERE ticket_reply_ticket_id = $ticket_id AND ticket_reply_archived_at IS NULL AND ticket_reply_type IN ('Public', 'Client') ORDER BY ticket_reply_id DESC"); $replies = mysqli_fetch_all($sql, MYSQLI_ASSOC); // Only what came before the reply the email shows - that one is the newest, but for a preview's sample $skip = (string) ($opts['skip'] ?? ''); if ($skip !== '') { foreach ($replies as $i => $reply) { if ((string) $reply['ticket_reply'] === $skip) { $replies = array_slice($replies, $i + 1); break; } } } foreach ($replies as $reply) { $text = ticketEmailExcerpt($reply['ticket_reply']); $client = $reply['ticket_reply_type'] === 'Client'; if ($text === '' || ($client && preg_match('/^Ticket (resolved|reopened|closed) by .{1,200}\.$/', $text))) { continue; } if (count($items) >= $limit) { $left_out++; continue; } $items[] = [ 'by' => $client ? ((string) $reply['contact_name'] ?: 'Client') : ((string) $reply['user_name'] ?: $company_name), 'org' => $client ? '' : $company_name, 'when' => date('D j M, g:i A', strtotime($reply['ticket_reply_created_at'])), 'text' => $text, 'client' => $client, ]; } } if (!($opts['request'] ?? true) && $left_out) { $items[] = ['gap' => $left_out]; } elseif ($opts['request'] ?? true) { $text = ticketEmailExcerpt($row['ticket_details'] ?? ''); if ($text !== '') { if ($left_out) { $items[] = ['gap' => $left_out]; } $asked_by = trim((string) ($row['contact_name'] ?? '')); $items[] = [ 'by' => $asked_by !== '' ? $asked_by : 'The request', 'action' => $asked_by !== '' ? 'the request' : '', 'when' => date('D j M, g:i A', strtotime($row['ticket_created_at'])), 'text' => $text, 'client' => true, ]; } } return $items ? ['label' => (string) ($opts['label'] ?? 'Earlier on this ticket'), 'items' => $items] : []; } /** A ticket message as a short plain-text excerpt - without the "Email from: ... :-" line the email parser puts on what it saves. */ function ticketEmailExcerpt($html, $limit = 700) { $html = preg_replace('#^\s*\s*Email from:.*?(\s*)*#is', '', (string) $html); return emailPlainText($html, $limit); } /* * What an email about a ticket says - emailHtml()'s fields (functions/email.php), * all but who it is to and why they get it, which ticketMailSend() adds for * each. $row is ticketRow(). $kind and its $opts: * created it was opened - how: agent (the default), email (the reply to an emailed one) or scheduled * reply an agent's reply - message (its HTML), by (who wrote it) * resolved marked resolved, pending closure - message (the agent's note, HTML, or ''), by * closed closed - asking how we did * watcher they were made a watcher - by (who added them) * scheduled the work was booked - when (words), where (the site's address), or cancelled: true */ function ticketEmailContent(array $row, $kind, array $opts = []) { global $config_ticket_autoclose_hours; $reference = ticketReference($row); $subject = (string) $row['ticket_subject']; $url = ticketEmailUrl($row); $company_name = (string) (billingCompany()['company_name'] ?? ''); $by = trim((string) ($opts['by'] ?? '')) !== '' ? trim((string) $opts['by']) : $company_name; $summary = ['Ticket' => $reference, 'Subject' => $subject, 'Status' => (string) ($row['ticket_status_name'] ?? '')]; $email = [ 'marker' => true, 'preview' => "$reference - $subject", 'tone' => 'primary', 'contact' => ticketEmailContactLine(), 'button' => ['label' => 'View the ticket', 'url' => $url], ]; if ($kind === 'created') { $how = (string) ($opts['how'] ?? 'agent'); $sla = ticketEmailSla($row['ticket_id']); $email['kind'] = 'New ticket'; if ($how === 'email') { $email['title'] = 'We have your email'; $email['intro'] = ["Thank you - it is ticket $reference now, and we will reply to you here. To add anything, just reply to this email."]; // Never their own words back: the sender of an email can be forged, and an echo would carry whatever it said to them } else { $email['title'] = $how === 'scheduled' ? 'A scheduled ticket was opened for you' : 'We have opened a ticket for you'; $email['intro'] = [($how === 'scheduled' ? "Ticket $reference was opened for you, as scheduled." : "We have opened ticket $reference for you.") . " We will keep you posted here - to add anything, just reply to this email."]; $email['message'] = ['html' => (string) $row['ticket_details'], 'label' => 'Details']; } $email['summary'] = $summary + ['Priority' => (string) $row['ticket_priority']] + $sla['summary']; $email['callout'] = $sla['callout']; $email['button']['label'] = 'View your ticket'; } elseif ($kind === 'reply') { $email['kind'] = 'Ticket update'; $email['title'] = $subject; $email['intro'] = ["There is a new reply on your ticket $reference."]; $email['message'] = ['html' => (string) ($opts['message'] ?? ''), 'label' => "$by replied"]; $email['summary'] = $summary; $email['after'] = ['To answer, just reply to this email.']; $email['history'] = ticketEmailHistory($row, ['skip' => (string) ($opts['message'] ?? '')]); } elseif ($kind === 'resolved') { $hours = intval($config_ticket_autoclose_hours); $closes = $hours > 0 ? ' - it closes by itself after ' . ($hours % 24 === 0 ? ($hours / 24) . ' day' . ($hours === 24 ? '' : 's') : "$hours hours") : ''; $email['kind'] = 'Resolved'; $email['tone'] = 'success'; $email['title'] = 'Your ticket is resolved'; $email['intro'] = ["We have marked ticket $reference resolved.", "If that sorts it, there is nothing more to do$closes. If not, reply to this email or reopen it and tell us what is still wrong."]; $email['message'] = ['html' => (string) ($opts['message'] ?? ''), 'label' => "$by wrote"]; $email['summary'] = $summary; $email['button']['label'] = 'View or reopen it'; // Resolved from the button or the board there is no note - the conversation says what was done $email['history'] = ticketEmailHistory($row, ['skip' => (string) ($opts['message'] ?? ''), 'label' => emailPlainText($opts['message'] ?? '') !== '' ? 'Earlier on this ticket' : 'The conversation']); } elseif ($kind === 'closed') { $email['marker'] = false; $email['kind'] = 'Closed'; $email['tone'] = 'secondary'; $email['title'] = 'Your ticket is closed'; $email['intro'] = ["Ticket $reference is closed. We hope it was sorted the way you wanted."]; $email['summary'] = $summary; $email['button']['label'] = 'Tell us how we did'; $email['after'] = ['Need more help with this? Raise a new ticket - replies to this email do not reach us.']; $email['history'] = ticketEmailHistory($row, ['limit' => 3, 'label' => 'The conversation']); } elseif ($kind === 'scheduled') { $cancelled = !empty($opts['cancelled']); $email['kind'] = $cancelled ? 'Visit cancelled' : 'Work booked'; $email['tone'] = $cancelled ? 'secondary' : 'primary'; $email['title'] = $cancelled ? 'The booked work is cancelled' : 'Your ticket is booked in'; $email['intro'] = [$cancelled ? "The work booked on ticket $reference is cancelled - the calendar file with this email takes it out of your calendar. We will be in touch to book it again." : "We have booked the work on ticket $reference for " . $opts['when'] . ". The calendar file with this email adds it to your calendar."]; $email['summary'] = $summary + ($cancelled ? [] : ['When' => (string) $opts['when'], 'Where' => !empty($row['ticket_onsite']) ? (trim((string) ($opts['where'] ?? '')) !== '' ? (string) $opts['where'] : 'Onsite') : 'Remote']); $email['history'] = $cancelled ? [] : ticketEmailHistory($row, ['limit' => 2, 'label' => 'What it is about']); } elseif ($kind === 'watcher') { $email['kind'] = 'Watching'; $email['title'] = 'You were added to a ticket'; $email['intro'] = ["$by added you to ticket $reference" . (!empty($row['client_name']) ? ' for ' . $row['client_name'] : '') . ", so you will get its updates. To add anything, just reply to this email."]; $email['message'] = ['html' => (string) $row['ticket_details'], 'label' => 'Details']; $email['summary'] = $summary; $email['history'] = ticketEmailHistory($row, ['request' => false, 'label' => 'Replies so far']); } return ticketEmailPreview($email); } /* * The inbox's preview line, beside the subject that already gives the number * and title - the start of the message the email carries, or else its first * line ("We have marked ticket TCK-12 resolved."). */ function ticketEmailPreview(array $email) { $said = emailPlainText($email['message']['html'] ?? '', 140); if ($said === '') { $said = (string) (((array) ($email['intro'] ?? []))[0] ?? ''); } if (trim($said) !== '') { $email['preview'] = preg_replace('/\s+/', ' ', trim($said)); } return $email; } /* * Queue an email about a ticket (ticketEmailContent()) to its contact, when * $to_contact and the address is good, and to every watcher, each told why * they get it. $subject is raw. Nothing is queued without a mail provider. * How many were queued. $opts: * attachments files to send with it ([['path', 'name'], ...]) * to_contact false leaves the contact out * to_watchers false is the contact alone - no watchers, no colleagues * to [['email', 'name'], ...] in place of the contact - the sender of an emailed ticket * kind created, closed or update (the default): a contact who asked * for replies only gets no created or closed email * (ticketContactWantsEmail()), and the client's primary and * technical contacts who asked for every update on their * company's tickets get the rest, as a watcher does * (ticketColleagueRecipients()) * ticket_reply_id the agent's reply it carries - each email is kept with it * (emailQueue()), for ticketReplyEmailed() */ function ticketMailSend($ticket_id, $subject, array $email, array $opts = []) { global $mysqli; if (!emailReady()) { return 0; } $ticket_id = intval($ticket_id); $kind = (string) ($opts['kind'] ?? 'update'); $to_watchers = $opts['to_watchers'] ?? true; $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_prefix, ticket_number, contact_name, contact_email, contact_ticket_emails FROM tickets LEFT JOIN contacts ON contact_id = ticket_contact_id WHERE ticket_id = $ticket_id")); if (!$row) { return 0; } $reference = ticketReference($row); $company_name = (string) (billingCompany()['company_name'] ?? 'us'); $recipients = []; if (isset($opts['to'])) { foreach ((array) $opts['to'] as $to) { $recipients[] = ['email' => $to['email'], 'name' => (string) ($to['name'] ?? ''), 'footnote' => "You get this because you emailed $company_name."]; } } elseif (($opts['to_contact'] ?? true) && ticketContactWantsEmail($row['contact_ticket_emails'] ?? 'all', $kind)) { $recipients[] = ['email' => (string) $row['contact_email'], 'name' => (string) $row['contact_name'], 'footnote' => "You get this as the contact on ticket $reference with $company_name."]; } if ($to_watchers) { $sql = mysqli_query($mysqli, "SELECT watcher_email FROM ticket_watchers WHERE watcher_ticket_id = $ticket_id"); while ($watcher = mysqli_fetch_assoc($sql)) { $recipients[] = ['email' => trim((string) $watcher['watcher_email']), 'greet' => '', 'footnote' => "You get this because you are watching ticket $reference - ask $company_name if you would like to stop."]; } // A new ticket reaches them by ticketMailColleaguesNew(), in an email of its own if ($kind !== 'created') { foreach (ticketColleagueRecipients($ticket_id, $kind) as $address => $colleague) { $recipients[] = ['email' => $address, 'name' => $colleague['name'], 'footnote' => "You get this because you asked for every update on " . $colleague['client_name'] . "'s tickets - change that under Your account in the client portal."]; } } } return count(emailQueue($recipients, $subject, $email, ['from' => ticketEmailFrom(), 'attachments' => $opts['attachments'] ?? [], 'cal_str' => $opts['cal_str'] ?? '', 'ticket_reply_id' => intval($opts['ticket_reply_id'] ?? 0)])); } /** Whether a ticket's contact gets an email of this $kind - no 'created' or 'closed' email for one who asked for replies only. */ function ticketContactWantsEmail($preference, $kind) { return !((string) $preference === 'replies' && in_array($kind, ['created', 'closed'], true)); } /** * The client's other primary and technical contacts who asked to hear about * their company's tickets (Your account in the portal) - for a 'created' * email those who chose new tickets or everything, otherwise those who chose * everything. Never the ticket's own contact, nor an archived one. * [email => ['name', 'client_name', 'portal' => bool]]. */ function ticketColleagueRecipients($ticket_id, $kind) { global $mysqli; $ticket = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_client_id, ticket_contact_id, client_name FROM tickets JOIN clients ON client_id = ticket_client_id WHERE ticket_id = " . intval($ticket_id) . " LIMIT 1")); if (!$ticket) { return []; } $wanted = $kind === 'created' ? "'new', 'all'" : "'all'"; $recipients = []; $sql = mysqli_query($mysqli, "SELECT contact_name, contact_email, user_id FROM contacts LEFT JOIN users ON user_id = contact_user_id AND user_type = 2 AND user_status = 1 AND user_archived_at IS NULL AND user_auth_method IN ('local', 'azure') WHERE contact_client_id = " . intval($ticket['ticket_client_id']) . " AND contact_id <> " . intval($ticket['ticket_contact_id']) . " AND contact_archived_at IS NULL AND (contact_primary = 1 OR contact_technical = 1) AND contact_company_ticket_emails IN ($wanted)"); while ($row = mysqli_fetch_assoc($sql)) { $email = trim((string) $row['contact_email']); if (filter_var($email, FILTER_VALIDATE_EMAIL)) { $recipients[$email] = ['name' => (string) $row['contact_name'], 'client_name' => (string) $ticket['client_name'], 'portal' => !empty($row['user_id'])]; } } return $recipients; } /** * Tell the colleagues who asked (ticketColleagueRecipients()) that a ticket * was raised at their company - who raised it, what about and how urgent, * with a link to it: in the portal for those who can sign in, else the * ticket's guest link. Raised in the portal, by email or by an agent; client * notifications have to be on (Admin > Ticket). How many were queued. */ function ticketMailColleaguesNew($ticket_id) { global $config_ticket_client_general_notifications, $config_base_url; if (!emailReady() || intval($config_ticket_client_general_notifications) !== 1) { return 0; } $ticket = ticketRow($ticket_id); if (!$ticket || !intval($ticket['ticket_client_id'])) { return 0; } $ticket_id = intval($ticket['ticket_id']); $reference = ticketReference($ticket); $raised_by = trim((string) $ticket['contact_name']) !== '' ? (string) $ticket['contact_name'] : 'We'; // What was asked - not for an emailed ticket, sent on by itself: a forged sender would have us carry their words to the client $echo = (string) $ticket['ticket_source'] !== 'Email'; $sent = 0; foreach (ticketColleagueRecipients($ticket_id, 'created') as $email => $colleague) { $url = $colleague['portal'] ? "https://$config_base_url/client/ticket.php?id=$ticket_id" : ticketEmailUrl($ticket); $sent += count(emailQueue([[ 'email' => $email, 'name' => $colleague['name'], 'footnote' => "You get this because you asked to hear about new tickets at " . $colleague['client_name'] . ($colleague['portal'] ? ' - change that under Your account in the client portal.' : '.'), ]], "New ticket - [$reference] - " . $ticket['ticket_subject'], [ 'kind' => 'New ticket', 'tone' => 'primary', 'preview' => ($echo ? preg_replace('/\s+/', ' ', emailPlainText($ticket['ticket_details'], 140)) : '') ?: "$reference - " . $ticket['ticket_subject'], 'title' => "New ticket at " . $colleague['client_name'], 'intro' => ["$raised_by raised a ticket at " . $colleague['client_name'] . '.'], 'message' => ['html' => $echo ? (string) $ticket['ticket_details'] : '', 'label' => 'Details'], 'summary' => ['Ticket' => $reference, 'Subject' => (string) $ticket['ticket_subject'], 'Priority' => (string) $ticket['ticket_priority'], 'Raised by' => $raised_by === 'We' ? 'Us' : $raised_by], 'button' => ['label' => $colleague['portal'] ? 'View it in the portal' : 'View the ticket', 'url' => $url], 'contact' => ticketEmailContactLine(), ], ['from' => ticketEmailFrom()])); } return $sent; } /* * The "a ticket was created for you" email - to its contact, and its watchers * unless $to_watchers is false; or to $to (ticketMailSend()'s) in their * place. $how agent, email or scheduled (ticketEmailContent()). */ function ticketMailCreated($ticket_id, $subject_prefix, $attachments = [], $to_watchers = true, $how = 'agent', ?array $to = null) { $row = ticketRow($ticket_id); if (!$row) { return 0; } return ticketMailSend($row['ticket_id'], "$subject_prefix [" . ticketReference($row) . "] - " . $row['ticket_subject'], ticketEmailContent($row, 'created', ['how' => $how]), ['attachments' => $attachments, 'to_watchers' => $to_watchers, 'kind' => 'created'] + ($to !== null ? ['to' => $to] : [])); } /* * A public reply on a ticket, to its contact and watchers - $reply_html as * saved (with the signature), $by who wrote it (raw). $resolved when the * reply resolved it. $reply_id, the reply in ticket_replies, has who it went * to kept with it (ticketReplyEmailed()). How many it went to. */ function ticketMailReply($ticket_id, $reply_html, $by, $resolved = false, $attachments = [], $reply_id = 0) { $row = ticketRow($ticket_id); if (!$row) { ticketReplyEmailed($reply_id, false); return 0; } $sent = ticketMailSend($row['ticket_id'], ($resolved ? 'Ticket resolved' : 'Ticket update') . " - [" . ticketReference($row) . "] - " . $row['ticket_subject'] . ($resolved ? ' | (pending closure)' : ''), ticketEmailContent($row, $resolved ? 'resolved' : 'reply', ['message' => (string) $reply_html, 'by' => (string) $by]), ['attachments' => $attachments, 'ticket_reply_id' => intval($reply_id)]); ticketReplyEmailed($reply_id, $sent > 0); return $sent; } /** Whether the 3.4.8 update has run - a reply keeps whether it was emailed and who to, and the mail queue which reply an email carried. */ function ticketReplyEmailReady() { global $mysqli; static $ready = null; if ($ready === null) { if (defined('CURRENT_DATABASE_VERSION')) { $version = CURRENT_DATABASE_VERSION; } else { $result = mysqli_query($mysqli, "SELECT config_current_database_version FROM settings WHERE company_id = 1"); $version = $result && ($row = mysqli_fetch_row($result)) ? (string) $row[0] : '0'; } $ready = version_compare($version, '3.4.8', '>='); } return $ready; } /* * Keep on an agent's reply whether it was emailed - and, once its emails are * queued with its id ('ticket_reply_id'), who they went to, read back from the * mail queue, so the conversation still says so after the queue has cleared * them. $emailed false for a public or internal note. Whether it went to anyone. */ function ticketReplyEmailed($reply_id, $emailed = true) { global $mysqli; $reply_id = intval($reply_id); if (!$reply_id || !ticketReplyEmailReady()) { return false; } $to = []; if ($emailed) { $sql = mysqli_query($mysqli, "SELECT email_recipient, email_recipient_name FROM email_queue WHERE email_ticket_reply_id = $reply_id ORDER BY email_id"); while ($row = mysqli_fetch_assoc($sql)) { $to[strtolower($row['email_recipient'])] = ['email' => (string) $row['email_recipient'], 'name' => (string) $row['email_recipient_name'] !== (string) $row['email_recipient'] ? (string) $row['email_recipient_name'] : '']; } } $json = $to ? "'" . escapeSql(json_encode(array_values($to), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . "'" : 'NULL'; // ticket_reply_updated_at kept as it is - it says when the words were edited mysqli_query($mysqli, "UPDATE ticket_replies SET ticket_reply_emailed = " . ($to ? 1 : 0) . ", ticket_reply_emailed_to = $json, ticket_reply_updated_at = ticket_reply_updated_at WHERE ticket_reply_id = $reply_id"); return (bool) $to; } /* * Who the agent replies in $rows were emailed to, and whether each email went - * for the conversation. $rows each have ticket_reply_id, ticket_reply_emailed * and ticket_reply_emailed_to. Reply id => ['to' => [['email', 'name', 'state' * => mailQueueState(), or null once the queue has cleared it]], 'key' => sent, * waiting (still to go, or being tried again), failed, cancelled, or gone (the * queue no longer has them - kept 90 days)], for each reply emailed. */ function ticketReplyEmailStates(array $rows) { global $mysqli; $states = []; foreach ($rows as $row) { if (intval($row['ticket_reply_emailed'] ?? 0) !== 1) { continue; } $to = []; foreach ((array) json_decode((string) ($row['ticket_reply_emailed_to'] ?? ''), true) as $recipient) { if (!empty($recipient['email'])) { $to[strtolower($recipient['email'])] = ['email' => (string) $recipient['email'], 'name' => (string) ($recipient['name'] ?? ''), 'state' => null]; } } $states[intval($row['ticket_reply_id'])] = ['to' => $to, 'key' => 'gone']; } if (!$states || !ticketReplyEmailReady()) { return array_map(function ($state) { $state['to'] = array_values($state['to']); return $state; }, $states); } $sql = mysqli_query($mysqli, "SELECT email_ticket_reply_id, email_recipient, email_recipient_name, email_status, email_attempts, email_queued_at, email_failed_at, email_sent_at, email_error FROM email_queue WHERE email_ticket_reply_id IN (" . implode(',', array_keys($states)) . ") ORDER BY email_id"); while ($sql && ($email = mysqli_fetch_assoc($sql))) { $reply_id = intval($email['email_ticket_reply_id']); $address = strtolower((string) $email['email_recipient']); $states[$reply_id]['to'][$address] = [ 'email' => (string) $email['email_recipient'], 'name' => $states[$reply_id]['to'][$address]['name'] ?? ((string) $email['email_recipient_name'] !== (string) $email['email_recipient'] ? (string) $email['email_recipient_name'] : ''), 'state' => mailQueueState($email), ]; } foreach ($states as $reply_id => $state) { $keys = array_filter(array_map(function ($recipient) { return $recipient['state']['key'] ?? null; }, $state['to'])); if (!$keys) { $key = 'gone'; } elseif (in_array('failed', $keys, true)) { $key = 'failed'; } elseif (array_intersect(['waiting', 'sending', 'retrying'], $keys)) { $key = 'waiting'; } elseif (!in_array('sent', $keys, true)) { $key = 'cancelled'; } else { $key = 'sent'; } $states[$reply_id] = ['to' => array_values($state['to']), 'key' => $key]; } return $states; } /** Tell people just made watchers of a ticket - $emails, by $by (raw). How many it went to. */ function ticketMailWatchersAdded($ticket_id, array $emails, $by) { $row = ticketRow($ticket_id); if (!$row || !$emails) { return 0; } $company_name = (string) (billingCompany()['company_name'] ?? 'us'); return count(emailQueue(array_map(function ($watcher_email) use ($row, $company_name) { return ['email' => $watcher_email, 'greet' => '', 'footnote' => "You get this because you are watching ticket " . ticketReference($row) . " - ask $company_name if you would like to stop."]; }, $emails), "Ticket Notification - [" . ticketReference($row) . "] - " . $row['ticket_subject'], ticketEmailContent($row, 'watcher', ['by' => (string) $by]), ['from' => ticketEmailFrom()])); } /* ------------------------------------------------------------ Emails about a ticket, to agents */ /** Where an agent opens a ticket - in ITFlow. */ function ticketAgentUrl(array $row) { global $config_base_url; return "https://$config_base_url/agent/" . ticketUrl($row['ticket_id'], $row['ticket_client_id'] ?? 0); } /** An agent to email - ['email', 'name'] of an active one, or null. */ function ticketMailAgent($user_id) { global $mysqli; $user_id = intval($user_id); $row = $user_id ? mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_name, user_email FROM users WHERE user_id = $user_id AND user_type = 1 AND user_status = 1 AND user_archived_at IS NULL LIMIT 1")) : null; return $row && filter_var($row['user_email'], FILTER_VALIDATE_EMAIL) ? ['email' => $row['user_email'], 'name' => $row['user_name']] : null; } /* * What an email to an agent about a ticket says - emailHtml()'s fields * (functions/email.php), all but who it is to and why. $row is ticketRow(). * $kind and its $opts: * new it was raised - how: portal, email or scheduled * replied the client replied - how: portal or email, message (the reply, HTML), by (who) * assigned it was assigned to them - by (who) * mentioned an internal note mentions them - by (who), message (the note, HTML), reply_id (the note, to open) * sla an SLA alert - track: response or resolution, breached: true once missed, due (when) * scheduled the work was booked - when, where, or cancelled: true */ function ticketAgentEmailContent(array $row, $kind, array $opts = []) { $reference = ticketReference($row); $subject = (string) $row['ticket_subject']; $client = !empty($row['client_name']) ? (string) $row['client_name'] : (intval($row['ticket_client_id'] ?? 0) ? 'A client' : 'No client'); $by = trim((string) ($opts['by'] ?? '')); $email = [ 'kind' => 'Ticket', 'tone' => 'primary', 'title' => $subject, 'preview' => "$reference - $subject", 'summary' => ['Client' => $client, 'Ticket' => $reference, 'Priority' => (string) $row['ticket_priority'], 'Status' => (string) ($row['ticket_status_name'] ?? '')], 'button' => ['label' => 'Open in ITFlow', 'url' => ticketAgentUrl($row)], ]; if ($kind === 'new') { $how = ['portal' => ' in the client portal', 'email' => ' by email', 'scheduled' => ', as scheduled'][$opts['how'] ?? ''] ?? ''; $who = ($opts['how'] ?? '') === 'scheduled' ? 'A recurring ticket was raised' : (trim((string) ($row['contact_name'] ?? '')) !== '' ? $row['contact_name'] . ' raised a ticket' : 'A ticket was raised'); $email['kind'] = 'New ticket'; $email['intro'] = [($opts['how'] ?? '') === 'scheduled' ? "$who for $client, as scheduled." : "$who$how for $client."]; $email['message'] = ['html' => (string) $row['ticket_details'], 'label' => 'Details']; $email['summary'] = ['Client' => $client, 'Ticket' => $reference, 'Priority' => (string) $row['ticket_priority']] + (trim((string) ($row['contact_name'] ?? '')) !== '' ? ['Contact' => (string) $row['contact_name']] : []); } elseif ($kind === 'replied') { $how = ['portal' => ' in the client portal', 'email' => ' by email'][$opts['how'] ?? ''] ?? ''; $email['kind'] = 'Client replied'; $email['intro'] = [($by !== '' ? $by : 'The client') . " replied to $reference$how."]; $email['message'] = ['html' => (string) ($opts['message'] ?? ''), 'label' => ($by !== '' ? $by : 'They') . ' wrote']; $email['after'] = ['Answer it in ITFlow, so the client gets your reply.']; $email['history'] = ticketEmailHistory($row, ['skip' => (string) ($opts['message'] ?? '')]); } elseif ($kind === 'mentioned') { $email['kind'] = 'Mentioned you'; $email['intro'] = [($by !== '' ? $by : 'Someone') . " mentioned you in an internal note on $reference."]; $email['message'] = ['html' => (string) ($opts['message'] ?? ''), 'label' => ($by !== '' ? $by : 'They') . ' wrote - only your team can see this']; $email['history'] = ticketEmailHistory($row, ['limit' => 2, 'label' => 'The conversation']); if (!empty($opts['reply_id'])) { $email['button'] = ['label' => 'Open the note', 'url' => ticketMentionUrl($row, $opts['reply_id'], true)]; } } elseif ($kind === 'assigned') { $email['kind'] = 'Assigned to you'; $email['intro'] = [($by !== '' ? $by : 'Someone') . " assigned $reference to you."]; $email['message'] = ['html' => (string) $row['ticket_details'], 'label' => 'Details']; $email['history'] = ticketEmailHistory($row, ['request' => false, 'label' => 'Replies so far']); } elseif ($kind === 'scheduled') { $cancelled = !empty($opts['cancelled']); $email['kind'] = $cancelled ? 'Visit cancelled' : 'Work booked'; $email['tone'] = $cancelled ? 'secondary' : 'primary'; $email['intro'] = [$cancelled ? "The work booked on $reference is cancelled - the calendar file with this email takes it out of your calendar." : "$reference is booked for " . $opts['when'] . " - the calendar file with this email adds it to your calendar."]; if (!$cancelled) { $email['summary'] += ['When' => (string) $opts['when'], 'Where' => !empty($row['ticket_onsite']) ? (trim((string) ($opts['where'] ?? '')) !== '' ? (string) $opts['where'] : 'Onsite') : 'Remote']; $email['history'] = ticketEmailHistory($row, ['limit' => 2, 'label' => 'What it is about']); } } elseif ($kind === 'sla') { $track = ($opts['track'] ?? 'response') === 'resolution' ? 'resolution' : 'response'; $due = !empty($opts['due']) ? date('D j M, g:i A', strtotime($opts['due'])) : ''; $breached = !empty($opts['breached']); $email['kind'] = ucfirst($track) . ($breached ? ' SLA missed' : ' SLA at risk'); $email['tone'] = $breached ? 'danger' : 'warning'; $email['intro'] = [$breached ? "The $track due on $reference by $due was missed." : "$reference is close to its $track SLA - it is due by $due."]; $email['summary'] += ['Assigned' => !empty($row['user_name']) ? (string) $row['user_name'] : 'Nobody', ucfirst($track) . ' due' => $due]; $email['history'] = ticketEmailHistory($row, ['limit' => 3, 'label' => 'The conversation']); } return ticketEmailPreview($email); } /** Queue an email to agents (ticketAgentEmailContent()) from the ticket mailbox - [['email', 'name', 'footnote'], ...]. How many went. */ function ticketMailAgents(array $recipients, $subject, array $email) { return count(emailQueue($recipients, $subject, $email, ['from' => ticketEmailFrom()])); } /* * Tell the new-ticket address (Admin > Ticket) a ticket was raised - $how: * portal, email or scheduled. Nothing when it isn't set. */ function ticketMailNewTicketAddress($ticket_id, $how) { global $mysqli, $config_app_name; $address = (string) (mysqli_fetch_row(mysqli_query($mysqli, "SELECT config_ticket_new_ticket_notification_email FROM settings WHERE company_id = 1"))[0] ?? ''); $row = ticketRow($ticket_id); if (!filter_var($address, FILTER_VALIDATE_EMAIL) || !$row) { return 0; } $client = !empty($row['client_name']) ? $row['client_name'] : 'Guest'; $subject = ($config_app_name ?: 'ITFlow') . ($how === 'scheduled' ? ' - New Recurring Ticket - ' : ' - New Ticket - ') . "$client: " . $row['ticket_subject']; return ticketMailAgents([['email' => $address, 'greet' => '', 'footnote' => 'You get this at the new-ticket address in Admin > Ticket.']], $subject, ticketAgentEmailContent($row, 'new', ['how' => $how])); } /** Tell the agent a ticket is assigned to that the client replied - $how portal or email, $reply its HTML, $by who. */ function ticketMailAssigneeReplied($ticket_id, $how, $reply, $by) { global $config_app_name; $row = ticketRow($ticket_id); $agent = $row ? ticketMailAgent($row['ticket_assigned_to']) : null; if (!$agent) { return 0; } return ticketMailAgents([$agent + ['footnote' => 'You get this as the agent ' . ticketReference($row) . ' is assigned to.']], ($config_app_name ?: 'ITFlow') . " - Ticket updated - [" . ticketReference($row) . "] " . $row['ticket_subject'], ticketAgentEmailContent($row, 'replied', ['how' => $how, 'message' => (string) $reply, 'by' => (string) $by])); } /* * Tell an agent tickets are theirs now - one email for any number. Not when * they assigned them themselves. $ticket_ids, $by raw. How many went. */ function ticketMailAssigned($user_id, array $ticket_ids, $by) { global $session_user_id, $config_app_name, $config_base_url; $agent = intval($user_id) !== intval($session_user_id ?? 0) ? ticketMailAgent($user_id) : null; $rows = array_values(array_filter(array_map('ticketRow', array_unique(array_map('intval', $ticket_ids))))); if (!$agent || !$rows) { return 0; } $app = $config_app_name ?: 'ITFlow'; $agent['footnote'] = 'You get this because ' . (count($rows) === 1 ? 'the ticket was' : 'they were') . ' assigned to you in ITFlow.'; if (count($rows) === 1) { $row = $rows[0]; return ticketMailAgents([$agent], "$app - Ticket " . ticketReference($row) . " assigned to you - " . $row['ticket_subject'], ticketAgentEmailContent($row, 'assigned', ['by' => $by])); } $lines = []; foreach ($rows as $row) { $lines[] = [['text' => ticketReference($row), 'url' => ticketAgentUrl($row)], (string) $row['ticket_subject'], !empty($row['client_name']) ? (string) $row['client_name'] : '-', (string) $row['ticket_priority']]; } return ticketMailAgents([$agent], "$app - " . count($rows) . " tickets have been assigned to you", [ 'kind' => 'Assigned to you', 'tone' => 'primary', 'preview' => "$by assigned " . count($rows) . ' tickets to you', 'title' => count($rows) . ' tickets are yours', 'intro' => ["$by assigned these tickets to you."], 'lines' => ['head' => ['Ticket', 'Subject', 'Client', 'Priority'], 'rows' => $lines], 'button' => ['label' => 'Open your tickets', 'url' => "https://$config_base_url/agent/tickets.php?assigned=" . intval($user_id)], ]); } /* * Tell an agent recurring tickets are theirs now - the tickets they raise * will be. $subjects (raw) of the recurring tickets, $by who did it. Not when * they did it themselves. */ function recurringTicketMailAssigned($user_id, array $subjects, $by) { global $session_user_id, $config_app_name, $config_base_url; $agent = intval($user_id) !== intval($session_user_id ?? 0) ? ticketMailAgent($user_id) : null; if (!$agent || !$subjects) { return 0; } $count = count($subjects); $agent['footnote'] = 'You get this because ' . ($count === 1 ? 'it was' : 'they were') . ' assigned to you in ITFlow.'; return ticketMailAgents([$agent], ($config_app_name ?: 'ITFlow') . ($count === 1 ? " - Recurring ticket " . $subjects[0] . " assigned to you" : " - $count recurring tickets assigned to you"), [ 'kind' => 'Assigned to you', 'tone' => 'primary', 'preview' => "$by assigned recurring tickets to you", 'title' => $count === 1 ? 'A recurring ticket is yours' : "$count recurring tickets are yours", 'intro' => ["$by assigned " . ($count === 1 ? 'this recurring ticket' : 'these recurring tickets') . " to you - the tickets " . ($count === 1 ? 'it raises' : 'they raise') . " will be yours."], 'lines' => ['head' => ['Recurring ticket'], 'rows' => array_map(function ($subject) { return [(string) $subject]; }, array_values($subjects))], 'button' => ['label' => 'Open Recurring Tickets', 'url' => "https://$config_base_url/agent/recurring_tickets.php?assigned_agent=" . intval($user_id)], ]); } /* * Ask for a task's approval by email - the agent named, or the client's: the * ticket's contact, or its technical or billing contacts. $by (raw) asked. * How many it went to. */ function ticketMailTaskApproval($approval_id, $by) { global $mysqli, $config_base_url, $session_user_id; $approval = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT approval_id, approval_scope, approval_type, approval_required_user_id, approval_url_key, task_name, task_ticket_id FROM task_approvals JOIN tasks ON task_id = approval_task_id WHERE approval_id = " . intval($approval_id))); $row = $approval ? ticketRow($approval['task_ticket_id']) : null; if (!$row) { return 0; } $reference = ticketReference($row); $client_id = intval($row['ticket_client_id']); $type = (string) $approval['approval_type']; $recipients = []; if ($approval['approval_scope'] === 'internal' && $type === 'specific' && intval($approval['approval_required_user_id']) !== intval($session_user_id)) { if ($agent = ticketMailAgent($approval['approval_required_user_id'])) { $recipients[] = $agent + ['footnote' => "You get this because $by asked you to approve it."]; } } elseif ($approval['approval_scope'] === 'client') { $where = $type === 'technical' ? "contact_client_id = $client_id AND contact_technical = 1" : ($type === 'billing' ? "contact_client_id = $client_id AND contact_billing = 1" : "contact_id = " . intval($row['ticket_contact_id'])); $sql_contacts = mysqli_query($mysqli, "SELECT contact_name, contact_email FROM contacts WHERE $where AND contact_archived_at IS NULL AND contact_email != ''"); while ($contact = mysqli_fetch_assoc($sql_contacts)) { $recipients[] = ['email' => $contact['contact_email'], 'name' => $contact['contact_name'], 'footnote' => $type === 'any' ? "You get this as the contact on ticket $reference." : "You get this as a $type contact for " . $row['client_name'] . "."]; } } return count(emailQueue($recipients, "Ticket task approval required - [$reference] - " . $row['ticket_subject'], [ 'marker' => true, 'kind' => 'Approval needed', 'tone' => 'warning', 'preview' => "$by needs your approval - " . $approval['task_name'], 'title' => 'Your approval is needed', 'intro' => ["$by needs your approval for a task on ticket $reference before the work goes ahead."], 'summary' => ['Task' => (string) $approval['task_name'], 'Ticket' => $reference, 'Subject' => (string) $row['ticket_subject'], 'Status' => (string) $row['ticket_status_name']], 'button' => ['label' => 'Review and approve', 'url' => "https://$config_base_url/guest/guest_approve_ticket_task.php?task_approval_id=" . intval($approval['approval_id']) . '&url_key=' . rawurlencode((string) $approval['approval_url_key'])], 'after' => ['Want to know more first? Just reply to this email.'], 'contact' => ticketEmailContactLine(), ], ['from' => ticketEmailFrom()])); } /* * Tell someone who replied to a closed ticket by email that it is closed, so * their reply reached nobody - and how to raise a new one. $from_email raw. */ function ticketMailClosedReply($ticket_id, $from_email) { $closed = ticketRow($ticket_id); if (!$closed) { return 0; } $support = ticketEmailFrom()['email']; return count(emailQueue([['email' => (string) $from_email, 'greet' => '', 'footnote' => 'You get this because you replied to a closed ticket.']], "Action required: This ticket is already closed", [ 'kind' => 'Ticket closed', 'tone' => 'warning', 'preview' => 'Your reply did not reach us - raise a new ticket instead', 'title' => 'That ticket is closed', 'intro' => ["You replied to ticket " . ticketReference($closed) . " - \"" . $closed['ticket_subject'] . "\" - which is closed, so your reply won't reach us.", "To raise a new ticket, send a new email to $support."], 'button' => ['label' => 'Email us', 'url' => "mailto:$support"], 'contact' => ticketEmailContactLine(), ], ['from' => ticketEmailFrom()])); } /* * An SLA alert on a ticket - in the app, and by email to the SLA alert address * (Admin > SLAs, $address) and the agent it is assigned to. $track response or * resolution; $breached true once it was missed; $due when it is or was due. */ function ticketSlaAlert($ticket_id, $track, $breached, $due, $address) { $row = ticketRow($ticket_id); if (!$row) { return 0; } $ticket_id = intval($row['ticket_id']); $ticket_ref = ticketReference($row); $subject_line = ucfirst($track) . ' SLA ' . ($breached ? 'breached' : 'at risk'); // appNotify inserts what it is given - escape at the boundary appNotify("Ticket SLA", escapeSql("$subject_line - $ticket_ref - " . $row['ticket_subject']), "/agent/ticket.php?ticket_id=$ticket_id", intval($row['ticket_client_id']), $ticket_id); $recipients = []; if (filter_var($address, FILTER_VALIDATE_EMAIL)) { $recipients[] = ['email' => $address, 'greet' => '', 'footnote' => 'You get this at the SLA alert address in Admin > SLAs.']; } // The assigned agent - once, if they are also the alert address if ($agent = ticketMailAgent($row['ticket_assigned_to'])) { $recipients[] = $agent + ['footnote' => "You get this as the agent $ticket_ref is assigned to."]; } return ticketMailAgents($recipients, "$subject_line: $ticket_ref - " . $row['ticket_subject'], ticketAgentEmailContent($row, 'sla', ['track' => $track, 'breached' => $breached, 'due' => $due])); } /** Time worked as posted - HH:MM:SS, or H:MM - normalised to HH:MM:SS; nothing sensible is 00:00:00. */ function ticketTimeWorked($input) { if (preg_match('/^\s*(\d{1,3}):([0-5]?\d)(?::([0-5]?\d))?\s*$/', (string) $input, $match)) { return sprintf('%02d:%02d:%02d', intval($match[1]), intval($match[2]), intval($match[3] ?? 0)); } return '00:00:00'; } /** Statuses a reply may leave the ticket in: the active ones but New and Closed (the system sets those), plus the one it has. */ function ticketReplyStatuses($current_status) { global $mysqli; $ids = [intval($current_status)]; $sql = mysqli_query($mysqli, "SELECT ticket_status_id FROM ticket_statuses WHERE ticket_status_id NOT IN (1, 5) AND ticket_status_active = 1"); while ($row = mysqli_fetch_row($sql)) { $ids[] = intval($row[0]); } return array_values(array_unique($ids)); } /** How many of a ticket's tasks are not done yet. */ function ticketOpenTaskCount($ticket_id) { global $mysqli; return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM tasks WHERE task_ticket_id = " . intval($ticket_id) . " AND task_completed_at IS NULL"))[0]); } /** * Tickets for another page's list - open ones first, then newest - each with * its status, agent and is_open. $where is SQL on tickets; $limit 0 is all. */ function ticketRows($where, $limit = 0) { global $mysqli; $limit = intval($limit); $sql = mysqli_query($mysqli, "SELECT tickets.ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_priority, ticket_vendor_ticket_number, ticket_created_at, ticket_updated_at, ticket_resolved_at, ticket_closed_at, ticket_assigned_to, ticket_client_id, user_name, user_avatar, ticket_status_id, ticket_status_name, ticket_status_color FROM tickets LEFT JOIN users ON ticket_assigned_to = user_id LEFT JOIN ticket_statuses ON ticket_status_id = ticket_status WHERE ($where) AND ticket_archived_at IS NULL ORDER BY (ticket_resolved_at IS NULL AND ticket_closed_at IS NULL) DESC, ticket_number DESC" . ($limit ? " LIMIT $limit" : '')); $rows = []; while ($row = mysqli_fetch_assoc($sql)) { $row['is_open'] = ticketIsOpen($row); $rows[] = $row; } return $rows; } /** The SQL for "tickets about this asset" - its primary asset or one of its others. */ function ticketAssetSql($asset_id) { $asset_id = intval($asset_id); return "tickets.ticket_asset_id = $asset_id OR tickets.ticket_id IN (SELECT ticket_assets.ticket_id FROM ticket_assets WHERE ticket_assets.asset_id = $asset_id)"; } /** How many tickets match, and how many of those are open - [all, open]. */ function ticketCounts($where) { global $mysqli; $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*), COALESCE(SUM(ticket_resolved_at IS NULL AND ticket_closed_at IS NULL), 0) FROM tickets WHERE ($where) AND ticket_archived_at IS NULL")); return [intval($row[0]), intval($row[1])]; } /** * Tickets as a table - the one used by the asset, contact, site, vendor and * project pages and their quick looks. Options: 'compact' (a quick look's * table: no header, no priority), 'vendor_ref' (their reference for it, to * copy), 'client' (a Client column, for lists across clients). */ function ticketTableHtml(array $rows, $options = []) { $compact = !empty($options['compact']); $vendor_ref = !empty($options['vendor_ref']); $with_client = !empty($options['client']); $html = '
'; if (!$compact) { $html .= '' . ($vendor_ref ? '' : '') . ($with_client ? '' : '') . ''; } $html .= ''; 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 .= '' . '' . ''; if ($vendor_ref) { $html .= ''; } if ($with_client) { $html .= ''; } if (!$compact) { $html .= ''; } $html .= '' . '' . '' . ''; } return $html . '
TicketSubjectTheir referenceClientPriorityStatusAssignedUpdated
' . escapeHtml(ticketReference($row)) . '' . escapeHtml($row['ticket_subject']) . '' . ($their_ref !== '' ? '' . $their_ref . '' : '-') . '' . escapeHtml($row['client_name'] ?? '') . '' . ticketPriorityHtml($row['ticket_priority'], 'detail') . '' . ticketStatusHtml($row['ticket_status_name'] ?? '', $row['ticket_status_color'] ?? '', 'detail') . '' . formatAssignee($row['ticket_assigned_to'], $row['user_name'] ?? '', $row['user_avatar'] ?? '', $is_open) . '' . timeAgo($last) . '
'; } /** * The other open tickets for the same contact or the same asset as this one * (or as the contact and asset given, for a ticket not yet created) - so a * duplicate or a related problem shows up. Each row says which it shares. */ function ticketAlsoOpen($contact_id, $asset_id, $exclude_ticket_id = 0, $limit = 6) { global $mysqli; $contact_id = intval($contact_id); $asset_id = intval($asset_id); $exclude_ticket_id = intval($exclude_ticket_id); $match = []; if ($contact_id) { $match[] = "tickets.ticket_contact_id = $contact_id"; } if ($asset_id) { $match[] = ticketAssetSql($asset_id); } if (!$match) { return []; } $rows = ticketRows('(' . implode(' OR ', $match) . ") AND tickets.ticket_id <> $exclude_ticket_id AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL", $limit); foreach ($rows as &$row) { $shares = []; $ticket_id = intval($row['ticket_id']); $full = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_contact_id, ticket_asset_id FROM tickets WHERE ticket_id = $ticket_id")); if ($contact_id && intval($full['ticket_contact_id']) === $contact_id) { $shares[] = 'contact'; } if ($asset_id && (intval($full['ticket_asset_id']) === $asset_id || mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM ticket_assets WHERE ticket_id = $ticket_id AND asset_id = $asset_id LIMIT 1")))) { $shares[] = 'asset'; } $row['shares'] = $shares; } return $rows; } /** Mark a ticket resolved - status, SLA, history, audit and the custom action. The caller has checked it may be. */ function ticketResolve($row, $how = '') { global $mysqli, $session_name; $ticket_id = intval($row['ticket_id']); if (empty($row['ticket_first_response_at'])) { setTicketFirstResponse($ticket_id); } mysqli_query($mysqli, "UPDATE tickets SET ticket_status = " . TICKET_STATUS_RESOLVED . ", ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id"); syncTicketSlaClock($ticket_id); setTicketResolutionSlaMet($ticket_id); logTicketHistory($ticket_id, "$session_name resolved the ticket$how"); logAudit("Ticket", "Resolve", "$session_name resolved ticket " . escapeSql(ticketReference($row)) . " - " . escapeSql($row['ticket_subject']), intval($row['ticket_client_id']), $ticket_id); triggerCustomAction('ticket_resolve', $ticket_id); } /** Bring a resolved (not closed) ticket back into a working status. The caller has checked it may be. */ function ticketReopen($row, $status_id = TICKET_STATUS_OPEN, $how = '') { global $mysqli, $session_name; $ticket_id = intval($row['ticket_id']); $status_id = intval($status_id); mysqli_query($mysqli, "UPDATE tickets SET ticket_status = $status_id, ticket_resolved_at = NULL WHERE ticket_id = $ticket_id"); syncTicketSlaClock($ticket_id); resetTicketResolutionSla($ticket_id); $status_name = escapeSql(getTicketStatusName($status_id)); logTicketHistory($ticket_id, "$session_name reopened the ticket" . ($status_id === TICKET_STATUS_OPEN ? '' : " to $status_name") . $how); logAudit("Ticket", "Reopen", "$session_name reopened ticket " . escapeSql(ticketReference($row)), intval($row['ticket_client_id']), $ticket_id); triggerCustomAction('ticket_update', $ticket_id); } /** The "your ticket is closed" email - to its contact and watchers, asking how we did. */ function ticketMailClosed($ticket_id) { $row = ticketRow($ticket_id); if (!$row) { return 0; } return ticketMailSend($row['ticket_id'], "Ticket closed - [" . ticketReference($row) . "] - " . $row['ticket_subject'] . " | (do not reply)", ticketEmailContent($row, 'closed'), ['kind' => 'closed']); } /* * The "Emailed to ..." line on an agent's reply in the conversation, and the * panel it opens - each recipient and whether their email went. $state is * the reply's from ticketReplyEmailStates(); $queue_url the Mail Queue for * this ticket's email, for admins, or ''. ['line' => html, 'panel' => html]. */ function ticketReplyEmailHtml($reply_id, array $state, $queue_url = '') { $reply_id = intval($reply_id); $to = $state['to'] ?? []; if (!$to) { // Marked emailed, but who to wasn't kept return ['line' => 'Emailed', 'panel' => '']; } $who = function ($recipient) { return $recipient['name'] !== '' ? $recipient['name'] : $recipient['email']; }; $first = escapeHtml($who($to[0])) . (count($to) > 1 ? ' and ' . (count($to) - 1) . ' more' : ''); $failed = count(array_filter($to, function ($recipient) { return ($recipient['state']['key'] ?? '') === 'failed'; })); switch ($state['key'] ?? 'gone') { case 'sent': [$icon, $text, $title] = ['fa-check text-success', "Emailed to $first", 'Sent']; break; case 'waiting': [$icon, $text, $title] = ['fa-hourglass-half text-primary', "Sending to $first", 'Waiting in the mail queue']; break; case 'failed': [$icon, $text, $title] = ['fa-exclamation-circle text-danger', $failed === count($to) ? "Couldn't email $first" : "Emailed to $first · $failed not sent", 'Not sent']; break; case 'cancelled': [$icon, $text, $title] = ['fa-ban', "Email to $first cancelled", 'Cancelled']; break; default: [$icon, $text, $title] = ['fa-paper-plane', "Emailed to $first", '']; } $line = ''; $items = ''; foreach ($to as $recipient) { $item_state = $recipient['state']; $address = $recipient['name'] !== '' ? escapeHtml($recipient['name']) . ' <' . escapeHtml($recipient['email']) . '>' : escapeHtml($recipient['email']); $items .= '
  • ' . ($item_state ? '' . escapeHtml($item_state['label']) . '' : '') . '' . $address . '' . ($item_state && $item_state['detail'] !== '' ? '' . escapeHtml($item_state['detail']) . '' : '') . ($item_state && $item_state['error'] !== '' ? '' . escapeHtml($item_state['error']) . '' : '') . '
  • '; } $gone = !array_filter(array_column($to, 'state')); $panel = '
    ' . '
    ' . '
      ' . $items . '
    ' . ($gone ? '
    The mail queue keeps email ' . MAIL_QUEUE_KEEP_DAYS . ' days, so whether each one went is no longer known.
    ' : '') . ($queue_url !== '' ? 'This ticket\'s email in the Mail Queue' : '') . '
    '; return ['line' => $line, 'panel' => $panel]; } /* * The "your ticket is resolved, pending closure" email - to its contact and * watchers. $note is the agent's words as HTML, raw, or ''; $reply_id the * reply they were saved as, which keeps who it went to (ticketReplyEmailed()). */ function ticketMailResolved($ticket_id, $note = '', $reply_id = 0) { global $session_name; $row = ticketRow($ticket_id); if (!$row) { ticketReplyEmailed($reply_id, false); return 0; } $sent = ticketMailSend($row['ticket_id'], "Ticket resolved - [" . ticketReference($row) . "] - " . $row['ticket_subject'] . " | (pending closure)", ticketEmailContent($row, 'resolved', ['message' => (string) $note, 'by' => stripslashes((string) ($session_name ?? ''))]), ['ticket_reply_id' => intval($reply_id)]); ticketReplyEmailed($reply_id, $sent > 0); return $sent; } /* * ---------- the ticket list and board ---------- * * Whose move it is. A ticket is waiting on us until we reply where the * client can see it; after that it waits on them until they write back. * Internal notes change neither. A status that pauses the SLA clock (On * Hold, or one of yours like "Waiting on vendor") parks it - waiting, too. */ /** SQL: still being worked - neither resolved nor closed. On tickets. */ const TICKET_OPEN_SQL = "(tickets.ticket_resolved_at IS NULL AND tickets.ticket_closed_at IS NULL)"; /** * SQL for who spoke last where the client can see it: 'Client', 'Public' * (us), or NULL when nobody has replied yet. Correlated on tickets, so it * goes after the open-ticket test in a condition - MySQL stops at the first * false AND, and it then only runs for open tickets. */ function ticketLastSpokeSql() { return "(SELECT tr.ticket_reply_type FROM ticket_replies tr WHERE tr.ticket_reply_ticket_id = tickets.ticket_id AND tr.ticket_reply_archived_at IS NULL AND tr.ticket_reply_type IN ('Public', 'Client') ORDER BY tr.ticket_reply_id DESC LIMIT 1)"; } /** SQL: open, not parked, and the client spoke last (or nobody has yet). Needs tickets joined to ticket_statuses. */ function ticketNeedsReplySql() { return TICKET_OPEN_SQL . " AND COALESCE(ticket_statuses.ticket_status_pauses_sla, 0) = 0 AND COALESCE(" . ticketLastSpokeSql() . ", 'Client') = 'Client'"; } /** SQL: open, and parked or we spoke last. Needs tickets joined to ticket_statuses. */ function ticketWaitingSql() { return TICKET_OPEN_SQL . " AND (COALESCE(ticket_statuses.ticket_status_pauses_sla, 0) = 1 OR " . ticketLastSpokeSql() . " = 'Public')"; } /** SQL: open and past its due date, or its SLA is breached. */ function ticketOverdueSql() { return TICKET_OPEN_SQL . " AND ((tickets.ticket_due_at IS NOT NULL AND tickets.ticket_due_at < NOW()) OR (tickets.ticket_sla_id > 0 AND (tickets.ticket_response_sla_alert_stage = 2 OR tickets.ticket_resolution_sla_alert_stage = 2)))"; } /** Whose move: 'us', 'client', 'parked', or '' for a ticket that is done. From a row with ticket_last_spoke and ticket_status_pauses_sla. */ function ticketWhoseMove($row) { if (!ticketIsOpen($row)) { return ''; } if (intval($row['ticket_status_pauses_sla'] ?? 0)) { return 'parked'; } return ($row['ticket_last_spoke'] ?? null) === 'Public' ? 'client' : 'us'; } /** * Where its SLA stands: ['key' => breached | at_risk | paused | running | * met | missed | none, 'due' => the clock still running's deadline, 'what']. * A breach recorded stands even while paused - it already happened. */ function ticketSlaState($row) { if (!intval($row['ticket_sla_id'] ?? 0)) { return ['key' => 'none']; } $stage = max(intval($row['ticket_response_sla_alert_stage'] ?? 0), intval($row['ticket_resolution_sla_alert_stage'] ?? 0)); if (!ticketIsOpen($row)) { if ($stage === 2 || ($row['ticket_response_sla_met'] ?? null) === '0' || ($row['ticket_resolution_sla_met'] ?? null) === '0') { return ['key' => 'missed']; } $met = intval($row['ticket_response_sla_met'] ?? 0) && (empty($row['ticket_resolution_due_at']) || intval($row['ticket_resolution_sla_met'] ?? 0)); return ['key' => $met ? 'met' : 'none']; } $responded = !empty($row['ticket_first_response_at']); $state = [ 'due' => $responded ? ($row['ticket_resolution_due_at'] ?? null) : ($row['ticket_response_due_at'] ?? null), 'what' => $responded ? 'Resolution due' : 'First response due', ]; if ($stage === 2) { return ['key' => 'breached'] + $state; } if (intval($row['ticket_status_pauses_sla'] ?? 0)) { return ['key' => 'paused'] + $state; } if ($stage === 1) { return ['key' => 'at_risk'] + $state; } return ['key' => empty($state['due']) ? 'none' : 'running'] + $state; } /** The SLA as a small pill - '' when there is nothing to say. */ function ticketSlaPill($sla) { $due = !empty($sla['due']) ? ' title="' . escapeHtml($sla['what'] . ' ' . $sla['due']) . '"' : ''; switch ($sla['key']) { case 'breached': return '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_ticket_client_general_notifications; $row = recurringTicketRow($recurring_ticket_id); if (!$row) { return 0; } // The settings, read here: the cron keeps its own copies SQL-escaped, the pages don't $settings = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_ticket_prefix, config_ticket_from_email, config_ticket_from_name, config_ticket_new_ticket_notification_email, config_ticket_client_general_notifications, config_smtp_provider FROM settings WHERE company_id = 1")); $config_ticket_prefix = $settings['config_ticket_prefix'] ?? ''; $config_ticket_from_email = $settings['config_ticket_from_email'] ?? ''; $config_ticket_from_name = $settings['config_ticket_from_name'] ?? ''; $config_ticket_new_ticket_notification_email = $settings['config_ticket_new_ticket_notification_email'] ?? ''; $config_ticket_client_general_notifications = intval($settings['config_ticket_client_general_notifications'] ?? 0); $config_smtp_provider = $settings['config_smtp_provider'] ?? ''; $recurring_ticket_id = intval($row['recurring_ticket_id']); $client_id = intval($row['recurring_ticket_client_id']); $subject = escapeSql($row['recurring_ticket_subject']); $details = mysqli_real_escape_string($mysqli, (string) $row['recurring_ticket_details']); $priority = escapeSql(ticketPriority($row['recurring_ticket_priority'])); $frequency = strtolower((string) $row['recurring_ticket_frequency']); $billable = intval($row['recurring_ticket_billable']); $created_by = intval($row['recurring_ticket_created_by']); $assigned_to = ticketAgentId($row['recurring_ticket_assigned_to']); // What it links to has to still be the client's $contact_id = ticketClientOwnedId('contact', $row['recurring_ticket_contact_id'], $client_id); $asset_id = ticketClientOwnedId('asset', $row['recurring_ticket_asset_id'], $client_id); $category_id = ticketCategoryId($row['recurring_ticket_category']); $ticket_status = $assigned_to ? TICKET_STATUS_OPEN : TICKET_STATUS_NEW; $url_key = randomString(32); $prefix = escapeSql($config_ticket_prefix); // Atomically increment and get the new ticket number mysqli_query($mysqli, "UPDATE settings SET config_ticket_next_number = LAST_INSERT_ID(config_ticket_next_number), config_ticket_next_number = config_ticket_next_number + 1 WHERE company_id = 1"); $ticket_number = mysqli_insert_id($mysqli); mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$prefix', ticket_number = $ticket_number, ticket_source = 'Recurring', ticket_subject = '$subject', ticket_details = '$details', ticket_priority = '$priority', ticket_status = $ticket_status, ticket_billable = $billable, ticket_url_key = '$url_key', ticket_created_by = $created_by, ticket_assigned_to = $assigned_to, ticket_contact_id = $contact_id, ticket_client_id = $client_id, ticket_asset_id = $asset_id, ticket_category = $category_id, ticket_recurring_ticket_id = $recurring_ticket_id"); $ticket_id = mysqli_insert_id($mysqli); if (!$ticket_id) { return 0; } applyTicketSla($ticket_id); // Its other assets that are still the client's, and its tasks mysqli_query($mysqli, "INSERT IGNORE INTO ticket_assets (ticket_id, asset_id) SELECT $ticket_id, recurring_ticket_assets.asset_id FROM recurring_ticket_assets JOIN assets ON assets.asset_id = recurring_ticket_assets.asset_id AND asset_client_id = $client_id WHERE recurring_ticket_id = $recurring_ticket_id AND recurring_ticket_assets.asset_id <> $asset_id"); addTasksFromRecurringTicket($ticket_id, $recurring_ticket_id); // The template it was made from, kept for the template's use, the answers it was given for // what the template asks, and its [Client], [Month] and the like filled in - every time // it is raised (functions/ticket_template.php) $template_id = intval($row['recurring_ticket_ticket_template_id'] ?? 0); if ($template_id && ticketTemplateRow($template_id)) { mysqli_query($mysqli, "UPDATE tickets SET ticket_template_id = $template_id WHERE ticket_id = $ticket_id"); } $fill_ins = ticketFillInsStored('recurring_ticket', $recurring_ticket_id); if ($fill_ins) { ticketFillInsStore('ticket', $ticket_id, $fill_ins); } ticketPlaceholdersFill($ticket_id); logAudit("Ticket", "Create", escapeSql($by) . " created recurring $frequency ticket $prefix$ticket_number - $subject", $client_id, $ticket_id); triggerCustomAction('ticket_create', $ticket_id); // The client is told (when general notifications are on) - the contact alone $new = ticketRow($ticket_id); if (intval($config_ticket_client_general_notifications) === 1 && $new) { ticketMailSend($ticket_id, "Ticket created - [" . ticketReference($new) . "] - " . $new['ticket_subject'] . " (scheduled)", ticketEmailContent($new, 'created', ['how' => 'scheduled']), ['kind' => 'created', 'to_watchers' => false]); } // The new-ticket address (Admin > Ticket), when there is one ticketMailNewTicketAddress($ticket_id, 'scheduled'); return $ticket_id; } /** * Raise a recurring ticket's ticket now and move its next run on: one step * past the run that was due, or past today when that was missed - so forcing * an early run takes the place of the next one, and nothing is raised twice. * The new ticket's id, or 0. */ function recurringTicketRunNow($row, $by) { global $mysqli; $recurring_ticket_id = intval($row['recurring_ticket_id']); $ticket_id = recurringTicketRaise($recurring_ticket_id, $by); if ($ticket_id) { $due = (string) $row['recurring_ticket_next_run']; $next_run = recurringTicketNextRun($due, $row['recurring_ticket_frequency'], $row['recurring_ticket_start_date'], max(date('Y-m-d'), substr($due, 0, 10))); mysqli_query($mysqli, "UPDATE recurring_tickets SET recurring_ticket_next_run = '$next_run' WHERE recurring_ticket_id = $recurring_ticket_id"); } return $ticket_id; } /** * For post handlers: the recurring ticket a request names, after the module * and client checks - or back with an error if it is gone. Sets the global * $client_id. */ function recurringTicketForRequest($recurring_ticket_id, $level) { global $client_id; enforceUserPermission('module_support', $level); $row = recurringTicketRow($recurring_ticket_id); if (!$row) { flashAlert('That recurring ticket no longer exists.', 'error'); redirect(); } $client_id = intval($row['recurring_ticket_client_id']); if ($client_id) { enforceClientAccess(); } return $row; } /** For post handlers: the recurring tickets a bulk action names (recurring_ticket_ids[]) - [id => row]. */ function recurringTicketsForBulk($level) { global $client_id; enforceUserPermission('module_support', $level); $rows = []; foreach (array_unique(array_map('intval', (array) ($_POST['recurring_ticket_ids'] ?? []))) as $recurring_ticket_id) { $row = recurringTicketRow($recurring_ticket_id); if (!$row) { continue; } $client_id = intval($row['recurring_ticket_client_id']); if ($client_id) { enforceClientAccess(); } $rows[$recurring_ticket_id] = $row; } return $rows; } /** A date posted from a date field as Y-m-d, or '' when it isn't one. */ function recurringTicketDate($input) { $input = trim((string) $input); $date = DateTime::createFromFormat('!Y-m-d', $input); return $date && $date->format('Y-m-d') === $input && $input <= '2999-12-31' ? $input : ''; } /** The next few runs from the next one - for showing what a schedule means. */ function recurringTicketUpcoming($row, $count = 3) { $runs = []; $date = substr((string) $row['recurring_ticket_next_run'], 0, 10); if (!recurringTicketDate($date)) { return $runs; } $runs[] = $date; while (count($runs) < $count) { $date = recurringTicketNextRun($date, $row['recurring_ticket_frequency'], $row['recurring_ticket_start_date'], $date); $runs[] = $date; } return $runs; } /** The last ticket each of these recurring tickets raised, with its status - [recurring id => row]. */ function recurringTicketLastTickets(array $recurring_ticket_ids) { global $mysqli; $ids = array_values(array_unique(array_filter(array_map('intval', $recurring_ticket_ids)))); if (!$ids) { return []; } $last = []; $sql = mysqli_query($mysqli, "SELECT ticket_recurring_ticket_id, MAX(ticket_id) FROM tickets WHERE ticket_recurring_ticket_id IN (" . implode(',', $ids) . ") GROUP BY ticket_recurring_ticket_id"); while ($row = mysqli_fetch_row($sql)) { $last[intval($row[1])] = intval($row[0]); } if (!$last) { return []; } $rows = []; foreach (ticketRows('tickets.ticket_id IN (' . implode(',', array_keys($last)) . ')') as $row) { $rows[$last[intval($row['ticket_id'])]] = $row; } return $rows; } /* * ---------- scheduled work ---------- * Booked from the ticket, or by dragging it on the calendar - both come here. */ /** Work out when a datetime-local value is - 'Y-m-d H:i:s', or '' when it isn't a date and time. */ function ticketScheduleValue($input) { $sql = ticketDueSql($input); return $sql === 'NULL' ? '' : trim($sql, "'"); } /* * The scheduled-work emails, each with the calendar file - the invite, or its * cancellation: to the agent doing the work, and when client notifications * are on (Admin > Ticket) to the contact and the watchers. $row has the * ticket's id and, for a booking, location_address. */ function ticketMailSchedule($row, $cal_str, $cancelled, $when_text) { global $config_ticket_client_general_notifications; $ticket = ticketRow($row['ticket_id']); if (!$ticket || !emailReady()) { return; } $reference = ticketReference($ticket); $opts = ['cancelled' => $cancelled, 'when' => $when_text, 'where' => (string) ($row['location_address'] ?? '')]; $subject = ($cancelled ? 'Ticket Schedule Cancelled' : 'Ticket Scheduled') . " - [$reference] - " . $ticket['ticket_subject']; if ($agent = ticketMailAgent($ticket['ticket_assigned_to'])) { emailQueue([$agent + ['footnote' => "You get this as the agent $reference is assigned to."]], $subject, ticketAgentEmailContent($ticket, 'scheduled', $opts), ['from' => ticketEmailFrom(), 'cal_str' => $cal_str]); } if (intval($config_ticket_client_general_notifications) === 1) { ticketMailSend($ticket['ticket_id'], $subject, ticketEmailContent($ticket, 'scheduled', $opts), ['cal_str' => $cal_str]); } } /** What the booking emails and the calendar file need of a ticket - the site is the ticket's location, else its contact's. */ function ticketScheduleRow($ticket_id) { global $mysqli; return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT tickets.ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_details, ticket_client_id, client_name, contact_name, contact_email, user_name, user_email, location_address FROM tickets LEFT JOIN clients ON ticket_client_id = client_id LEFT JOIN contacts ON ticket_contact_id = contact_id LEFT JOIN locations ON location_id = IF(ticket_location_id > 0, ticket_location_id, contact_location_id) LEFT JOIN users ON ticket_assigned_to = user_id WHERE tickets.ticket_id = " . intval($ticket_id))) ?: []; } /* * The calendar file for the work booked on a ticket (ticketScheduleRow()) at * $schedule - the one the booking emails carry, and Add to calendar on the * guest ticket page. The UID is the ticket's, so either one updates the same * event in the calendar. */ function ticketScheduleIcs(array $row, $schedule) { global $config_base_url; $ticket_id = intval($row['ticket_id'] ?? 0); $subject = intval($row['ticket_number'] ?? 0) . ": " . ($row['client_name'] ?? '') . " - " . ($row['ticket_subject'] ?? ''); $description = mb_substr(strip_tags((string) ($row['ticket_details'] ?? '')), 0, 100) . " - https://$config_base_url/client/ticket.php?id=$ticket_id"; return createiCalStr($schedule, $subject, $description, (string) ($row['location_address'] ?? ''), getTicketCalendarUid($ticket_id)); } /** * Book the work on a ticket for $schedule ('Y-m-d H:i:s'), onsite or remote: * the agent, the contact and the watchers are emailed with the calendar file, * and it is noted on the ticket. Returns ['when_text' => ..., 'conflicts' => * the same agent's other open work within two hours, as plain text]. */ function ticketSetSchedule(array $row, $schedule, $onsite) { global $mysqli, $session_user_id, $session_name; $ticket_id = intval($row['ticket_id']); $client_id = intval($row['ticket_client_id']); $onsite = $onsite ? 1 : 0; $schedule = escapeSql($schedule); mysqli_query($mysqli, "UPDATE tickets SET ticket_schedule = '$schedule', ticket_onsite = $onsite WHERE ticket_id = $ticket_id"); // The same agent's other open tickets within two hours of it $conflicts = []; $assigned_to = intval($row['ticket_assigned_to']); if ($assigned_to) { $start = date('Y-m-d H:i:s', strtotime($schedule) - 7200); $end = date('Y-m-d H:i:s', strtotime($schedule) + 7200); $sql = mysqli_query($mysqli, "SELECT ticket_prefix, ticket_number, ticket_schedule, ticket_subject FROM tickets WHERE ticket_schedule BETWEEN '$start' AND '$end' AND ticket_id <> $ticket_id AND ticket_assigned_to = $assigned_to AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL ORDER BY ticket_schedule"); while ($conflict = mysqli_fetch_assoc($sql)) { $conflicts[] = ticketReference($conflict) . ' - ' . $conflict['ticket_subject'] . ' @ ' . date('g:ia', strtotime($conflict['ticket_schedule'])); } } // Who to tell, with the calendar file $mail_row = ticketScheduleRow($ticket_id); $cal_str = ticketScheduleIcs($mail_row, $schedule); $when_text = date('l, F j, Y \a\t g:ia', strtotime($schedule)); ticketMailSchedule($mail_row, $cal_str, false, $when_text); // Note it on the ticket $note = escapeSql("Ticket scheduled for $when_text " . ($onsite ? '(onsite).' : '(remote).')); mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$note', ticket_reply_type = 'Internal', ticket_reply_time_worked = '00:00:00', ticket_reply_by = " . intval($session_user_id) . ", ticket_reply_ticket_id = $ticket_id"); logTicketHistory($ticket_id, "$session_name scheduled the work for " . escapeSql($when_text) . ($onsite ? ' (onsite)' : ' (remote)')); logAudit("Ticket", "Edit", "$session_name scheduled ticket " . escapeSql(ticketReference($row)) . " for " . escapeSql($when_text), $client_id, $ticket_id); triggerCustomAction('ticket_schedule', $ticket_id); return ['when_text' => $when_text, 'conflicts' => $conflicts]; } /** Take the scheduled work off a ticket, cancelling the calendar file everyone was sent. */ function ticketCancelSchedule(array $row) { global $mysqli, $session_user_id, $session_name; $ticket_id = intval($row['ticket_id']); $client_id = intval($row['ticket_client_id']); $old_schedule = $row['ticket_schedule']; mysqli_query($mysqli, "UPDATE tickets SET ticket_schedule = NULL WHERE ticket_id = $ticket_id"); $mail_row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT tickets.ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_client_id, client_name, contact_name, contact_email, user_name, user_email FROM tickets LEFT JOIN clients ON ticket_client_id = client_id LEFT JOIN contacts ON ticket_contact_id = contact_id LEFT JOIN users ON ticket_assigned_to = user_id WHERE tickets.ticket_id = $ticket_id")); $cal_subject = intval($mail_row['ticket_number']) . ": " . $mail_row['client_name'] . " - " . $mail_row['ticket_subject']; $cal_str = createiCalStrCancel($old_schedule, $cal_subject, getTicketCalendarUid($ticket_id)); ticketMailSchedule($mail_row, $cal_str, true, ''); mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = 'Ticket schedule cancelled.', ticket_reply_type = 'Internal', ticket_reply_time_worked = '00:00:00', ticket_reply_by = " . intval($session_user_id) . ", ticket_reply_ticket_id = $ticket_id"); logTicketHistory($ticket_id, "$session_name cancelled the scheduled work"); logAudit("Ticket", "Edit", "$session_name cancelled the schedule of ticket " . escapeSql(ticketReference($row)), $client_id, $ticket_id); triggerCustomAction('ticket_unschedule', $ticket_id); }