['days' => 3], 'Weekly' => ['days' => 7], 'Biweekly' => ['days' => 14], 'Monthly' => ['months' => 1], 'Quarterly' => ['months' => 3], 'Biannually' => ['months' => 6], 'Annually' => ['months' => 12], ]; /** May this agent do this with tickets? 1 is to see them, 2 to work them, 3 to delete them. */ function ticketCan($level) { return lookupUserPermission('module_support') >= $level; } /** One ticket by id, with its client, contact, status and agent - raw, or null. */ function ticketRow($ticket_id) { global $mysqli; $ticket_id = intval($ticket_id); if (!$ticket_id) { return null; } $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT tickets.*, client_name, contact_name, contact_email, ticket_status_name, ticket_status_color, user_name, user_avatar FROM tickets LEFT JOIN clients ON client_id = ticket_client_id LEFT JOIN contacts ON contact_id = ticket_contact_id LEFT JOIN ticket_statuses ON ticket_status_id = ticket_status LEFT JOIN users ON user_id = ticket_assigned_to WHERE ticket_id = $ticket_id LIMIT 1")); return $row ?: null; } /** * For post handlers: the ticket a request names, after the module and client * checks (a ticket with no client is internal work any agent may reach) - or * back with an error if it is gone. Sets the global $client_id. */ function ticketForRequest($ticket_id, $level) { global $client_id; enforceUserPermission('module_support', $level); $row = ticketRow($ticket_id); if (!$row) { flashAlert('That ticket no longer exists.', 'error'); redirect(); } $client_id = intval($row['ticket_client_id']); if ($client_id) { enforceClientAccess(); } return $row; } /** * For modals: the ticket a modal is opened for, after the module and client * checks - or the modal's error, and nothing more. $open_only refuses a * closed ticket. Sets the global $client_id. */ function ticketForModal($ticket_id, $level = 2, $open_only = false) { global $client_id; enforceUserPermission('module_support', $level); $row = ticketRow($ticket_id); if (!$row) { echo json_encode(['error' => 'That ticket no longer exists.']); exit; } $client_id = intval($row['ticket_client_id']); if ($client_id) { enforceClientAccess(); } if ($open_only && !empty($row['ticket_closed_at'])) { echo json_encode(['error' => 'Ticket ' . ticketReference($row) . ' is closed.']); exit; } return $row; } /** For post handlers: the tickets a bulk action names (ticket_ids[]) that this agent may act on - [id => row]. */ function ticketsForBulk($level) { global $client_id; enforceUserPermission('module_support', $level); $rows = []; foreach (array_unique(array_map('intval', (array) ($_POST['ticket_ids'] ?? []))) as $ticket_id) { $row = ticketRow($ticket_id); if (!$row) { continue; } $client_id = intval($row['ticket_client_id']); if ($client_id) { enforceClientAccess(); } $rows[$ticket_id] = $row; } return $rows; } /** Its reference - TCK-1234. */ function ticketReference($row) { return ($row['ticket_prefix'] ?? '') . intval($row['ticket_number'] ?? 0); } /** Is it still being worked - neither resolved nor closed? */ function ticketIsOpen($row) { return empty($row['ticket_resolved_at']) && empty($row['ticket_closed_at']); } /** Where the ticket page is - inside its client when it has one. */ function ticketUrl($ticket_id, $client_id = 0) { $client_id = intval($client_id); return 'ticket.php?' . ($client_id ? "client_id=$client_id&" : '') . 'ticket_id=' . intval($ticket_id); } /** A ticket wherever a page mentions one: its reference, then its subject, both opening the ticket. 'subject' => false for the reference alone. */ function ticketLink($row, $options = []) { $url = escapeHtml(ticketUrl($row['ticket_id'], $row['ticket_client_id'] ?? 0)); $reference = escapeHtml(ticketReference($row)); $html = '' . $reference . ''; if (($options['subject'] ?? true) && isset($row['ticket_subject'])) { $html .= ' ' . escapeHtml($row['ticket_subject']) . ''; } return $html; } /** * A record a ticket of this client may link to - its id when it is the * client's own, else 0. $kind is contact, asset, location, vendor or * project. A project may also be one with no client (internal work); a * ticket with no client may only link to our own vendors and projects. */ function ticketClientOwnedId($kind, $id, $client_id) { global $mysqli; $id = intval($id); $client_id = intval($client_id); if (!$id) { return 0; } if ($kind === 'project') { $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT project_id FROM projects WHERE project_id = $id AND project_client_id IN (0, $client_id) LIMIT 1")); return $row ? $id : 0; } if (!$client_id && $kind !== 'vendor') { return 0; } return assetClientOwnedId($kind, $id, $client_id); } /** The client's own assets out of a list of ids, without the primary one - for "other assets". */ function ticketClientOwnedAssetIds($ids, $client_id, $primary_asset_id = 0) { global $mysqli; $ids = array_values(array_diff(array_unique(array_filter(array_map('intval', (array) $ids))), [intval($primary_asset_id)])); $client_id = intval($client_id); if (!$ids || !$client_id) { return []; } $owned = []; $sql = mysqli_query($mysqli, "SELECT asset_id FROM assets WHERE asset_client_id = $client_id AND asset_id IN (" . implode(',', $ids) . ")"); while ($row = mysqli_fetch_row($sql)) { $owned[] = intval($row[0]); } return $owned; } /** Replace a ticket's other assets with these (already checked) ids. */ function ticketSetOtherAssets($ticket_id, array $asset_ids) { global $mysqli; $ticket_id = intval($ticket_id); mysqli_query($mysqli, "DELETE FROM ticket_assets WHERE ticket_id = $ticket_id"); foreach ($asset_ids as $asset_id) { mysqli_query($mysqli, "INSERT IGNORE INTO ticket_assets SET ticket_id = $ticket_id, asset_id = " . intval($asset_id)); } } /** An active agent's id, else 0 (unassigned). */ function ticketAgentId($user_id) { global $mysqli; $user_id = intval($user_id); if (!$user_id) { return 0; } $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT user_id FROM users WHERE user_id = $user_id AND user_type = 1 AND user_status = 1 AND user_archived_at IS NULL LIMIT 1")); return $row ? $user_id : 0; } /** A ticket category's id, else 0 (not categorised). */ function ticketCategoryId($category_id) { global $mysqli; $category_id = intval($category_id); if (!$category_id) { return 0; } $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT category_id FROM categories WHERE category_id = $category_id AND category_type = 'Ticket' LIMIT 1")); return $row ? $category_id : 0; } /** A priority as posted, or Medium when it isn't one. */ function ticketPriority($priority) { return in_array($priority, TICKET_PRIORITIES, true) ? $priority : 'Medium'; } /** A due date posted from a datetime-local field (with or without seconds) as SQL - quoted, or NULL. */ function ticketDueSql($input) { $input = trim((string) $input); if ($input === '') { return 'NULL'; } foreach (['Y-m-d\TH:i', 'Y-m-d\TH:i:s', 'Y-m-d H:i:s', 'Y-m-d H:i'] as $format) { $date = DateTime::createFromFormat("!$format", $input); if ($date && $date->format($format) === $input) { return "'" . $date->format('Y-m-d H:i:s') . "'"; } } return 'NULL'; } /** The watchers posted - valid email addresses only, each once, lower-cased. */ function ticketWatcherEmails($input) { $list = is_array($input) ? $input : preg_split('/[\s,;]+/', (string) $input); $emails = []; foreach ($list as $email) { $email = strtolower(trim((string) $email)); if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { $emails[$email] = true; } } return array_keys($emails); } /** Add watchers a ticket doesn't have already - the ones added. */ function ticketAddWatchers($ticket_id, array $emails) { global $mysqli; $ticket_id = intval($ticket_id); $added = []; foreach ($emails as $email) { $email_sql = escapeSql($email); if (mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM ticket_watchers WHERE watcher_ticket_id = $ticket_id AND watcher_email = '$email_sql' LIMIT 1"))) { continue; } mysqli_query($mysqli, "INSERT INTO ticket_watchers SET watcher_email = '$email_sql', watcher_ticket_id = $ticket_id"); $added[] = $email; } return $added; } /** * Delete a ticket and everything that is only its: replies, views, watchers, * attachments (and their files), tasks and their approvals, history and SLA * history. Its other-asset links go with it (ON DELETE CASCADE). */ function ticketDeleteForGood($ticket_id) { global $mysqli; $ticket_id = intval($ticket_id); if (!$ticket_id) { return; } mysqli_query($mysqli, "DELETE FROM task_approvals WHERE approval_task_id IN (SELECT task_id FROM tasks WHERE task_ticket_id = $ticket_id)"); mysqli_query($mysqli, "DELETE FROM tasks WHERE task_ticket_id = $ticket_id"); mysqli_query($mysqli, "DELETE FROM ticket_replies WHERE ticket_reply_ticket_id = $ticket_id"); mysqli_query($mysqli, "DELETE FROM ticket_views WHERE view_ticket_id = $ticket_id"); mysqli_query($mysqli, "DELETE FROM ticket_watchers WHERE watcher_ticket_id = $ticket_id"); mysqli_query($mysqli, "DELETE FROM ticket_attachments WHERE ticket_attachment_ticket_id = $ticket_id"); mysqli_query($mysqli, "DELETE FROM ticket_history WHERE ticket_history_ticket_id = $ticket_id"); mysqli_query($mysqli, "DELETE FROM sla_history WHERE sla_history_ticket_id = $ticket_id"); mysqli_query($mysqli, "DELETE FROM tickets WHERE ticket_id = $ticket_id"); // The driving for it still happened - the trips stay, without the ticket mysqli_query($mysqli, "UPDATE trips SET trip_ticket_id = 0 WHERE trip_ticket_id = $ticket_id"); removeDirectory(__DIR__ . "/../uploads/tickets/$ticket_id"); } /* ------------------------------------------------------------ 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"; } /** 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, ]; } /* * 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."]; } 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.']; } 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'; } 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.']; } 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']); } 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; } 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()) */ 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'] ?? ''])); } /** 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'; $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' => "$reference - " . $ticket['ticket_subject'], 'title' => "New ticket at " . $colleague['client_name'], 'intro' => ["$raised_by raised a ticket at " . $colleague['client_name'] . '.'], 'summary' => ['Ticket' => $reference, 'Subject' => (string) $ticket['ticket_subject'], 'Priority' => (string) $ticket['ticket_priority'], 'Raised by' => $raised_by === 'We' ? 'Us' : $raised_by], 'button' => ['label' => $colleague['portal'] ? 'View it in the portal' : 'View the ticket', 'url' => $url], '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. */ function ticketMailReply($ticket_id, $reply_html, $by, $resolved = false, $attachments = []) { $row = ticketRow($ticket_id); if (!$row) { return 0; } return 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]); } /** 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) * 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.']; } 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']; } 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']; } } 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]; } return $email; } /** Queue an email to agents (ticketAgentEmailContent()) from the ticket mailbox - [['email', 'name', 'footnote'], ...]. How many went. */ function ticketMailAgents(array $recipients, $subject, array $email) { return count(emailQueue($recipients, $subject, $email, ['from' => ticketEmailFrom()])); } /* * Tell the new-ticket address (Admin > Ticket) a ticket was raised - $how: * portal, email or scheduled. Nothing when it isn't set. */ function ticketMailNewTicketAddress($ticket_id, $how) { global $mysqli, $config_app_name; $address = (string) (mysqli_fetch_row(mysqli_query($mysqli, "SELECT config_ticket_new_ticket_notification_email FROM settings WHERE company_id = 1"))[0] ?? ''); $row = ticketRow($ticket_id); if (!filter_var($address, FILTER_VALIDATE_EMAIL) || !$row) { return 0; } $client = !empty($row['client_name']) ? $row['client_name'] : 'Guest'; $subject = ($config_app_name ?: 'ITFlow') . ($how === 'scheduled' ? ' - New Recurring Ticket - ' : ' - New Ticket - ') . "$client: " . $row['ticket_subject']; return ticketMailAgents([['email' => $address, 'greet' => '', 'footnote' => 'You get this at the new-ticket address in Admin > Ticket.']], $subject, ticketAgentEmailContent($row, 'new', ['how' => $how])); } /** Tell the agent a ticket is assigned to that the client replied - $how portal or email, $reply its HTML, $by who. */ function ticketMailAssigneeReplied($ticket_id, $how, $reply, $by) { global $config_app_name; $row = ticketRow($ticket_id); $agent = $row ? ticketMailAgent($row['ticket_assigned_to']) : null; if (!$agent) { return 0; } return ticketMailAgents([$agent + ['footnote' => 'You get this as the agent ' . ticketReference($row) . ' is assigned to.']], ($config_app_name ?: 'ITFlow') . " - Ticket updated - [" . ticketReference($row) . "] " . $row['ticket_subject'], ticketAgentEmailContent($row, 'replied', ['how' => $how, 'message' => (string) $reply, 'by' => (string) $by])); } /* * Tell an agent tickets are theirs now - one email for any number. Not when * they assigned them themselves. $ticket_ids, $by raw. How many went. */ function ticketMailAssigned($user_id, array $ticket_ids, $by) { global $session_user_id, $config_app_name, $config_base_url; $agent = intval($user_id) !== intval($session_user_id ?? 0) ? ticketMailAgent($user_id) : null; $rows = array_values(array_filter(array_map('ticketRow', array_unique(array_map('intval', $ticket_ids))))); if (!$agent || !$rows) { return 0; } $app = $config_app_name ?: 'ITFlow'; $agent['footnote'] = 'You get this because ' . (count($rows) === 1 ? 'the ticket was' : 'they were') . ' assigned to you in ITFlow.'; if (count($rows) === 1) { $row = $rows[0]; return ticketMailAgents([$agent], "$app - Ticket " . ticketReference($row) . " assigned to you - " . $row['ticket_subject'], ticketAgentEmailContent($row, 'assigned', ['by' => $by])); } $lines = []; foreach ($rows as $row) { $lines[] = [['text' => ticketReference($row), 'url' => ticketAgentUrl($row)], (string) $row['ticket_subject'], !empty($row['client_name']) ? (string) $row['client_name'] : '-', (string) $row['ticket_priority']]; } return ticketMailAgents([$agent], "$app - " . count($rows) . " tickets have been assigned to you", [ 'kind' => 'Assigned to you', 'tone' => 'primary', 'preview' => "$by assigned " . count($rows) . ' tickets to you', 'title' => count($rows) . ' tickets are yours', 'intro' => ["$by assigned these tickets to you."], 'lines' => ['head' => ['Ticket', 'Subject', 'Client', 'Priority'], 'rows' => $lines], 'button' => ['label' => 'Open your tickets', 'url' => "https://$config_base_url/agent/tickets.php?assigned=" . intval($user_id)], ]); } /* * Tell an agent recurring tickets are theirs now - the tickets they raise * will be. $subjects (raw) of the recurring tickets, $by who did it. Not when * they did it themselves. */ function recurringTicketMailAssigned($user_id, array $subjects, $by) { global $session_user_id, $config_app_name, $config_base_url; $agent = intval($user_id) !== intval($session_user_id ?? 0) ? ticketMailAgent($user_id) : null; if (!$agent || !$subjects) { return 0; } $count = count($subjects); $agent['footnote'] = 'You get this because ' . ($count === 1 ? 'it was' : 'they were') . ' assigned to you in ITFlow.'; return ticketMailAgents([$agent], ($config_app_name ?: 'ITFlow') . ($count === 1 ? " - Recurring ticket " . $subjects[0] . " assigned to you" : " - $count recurring tickets assigned to you"), [ 'kind' => 'Assigned to you', 'tone' => 'primary', 'preview' => "$by assigned recurring tickets to you", 'title' => $count === 1 ? 'A recurring ticket is yours' : "$count recurring tickets are yours", 'intro' => ["$by assigned " . ($count === 1 ? 'this recurring ticket' : 'these recurring tickets') . " to you - the tickets " . ($count === 1 ? 'it raises' : 'they raise') . " will be yours."], 'lines' => ['head' => ['Recurring ticket'], 'rows' => array_map(function ($subject) { return [(string) $subject]; }, array_values($subjects))], 'button' => ['label' => 'Open Recurring Tickets', 'url' => "https://$config_base_url/agent/recurring_tickets.php?assigned_agent=" . intval($user_id)], ]); } /* * Ask for a task's approval by email - the agent named, or the client's: the * ticket's contact, or its technical or billing contacts. $by (raw) asked. * How many it went to. */ function ticketMailTaskApproval($approval_id, $by) { global $mysqli, $config_base_url, $session_user_id; $approval = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT approval_id, approval_scope, approval_type, approval_required_user_id, approval_url_key, task_name, task_ticket_id FROM task_approvals JOIN tasks ON task_id = approval_task_id WHERE approval_id = " . intval($approval_id))); $row = $approval ? ticketRow($approval['task_ticket_id']) : null; if (!$row) { return 0; } $reference = ticketReference($row); $client_id = intval($row['ticket_client_id']); $type = (string) $approval['approval_type']; $recipients = []; if ($approval['approval_scope'] === 'internal' && $type === 'specific' && intval($approval['approval_required_user_id']) !== intval($session_user_id)) { if ($agent = ticketMailAgent($approval['approval_required_user_id'])) { $recipients[] = $agent + ['footnote' => "You get this because $by asked you to approve it."]; } } elseif ($approval['approval_scope'] === 'client') { $where = $type === 'technical' ? "contact_client_id = $client_id AND contact_technical = 1" : ($type === 'billing' ? "contact_client_id = $client_id AND contact_billing = 1" : "contact_id = " . intval($row['ticket_contact_id'])); $sql_contacts = mysqli_query($mysqli, "SELECT contact_name, contact_email FROM contacts WHERE $where AND contact_archived_at IS NULL AND contact_email != ''"); while ($contact = mysqli_fetch_assoc($sql_contacts)) { $recipients[] = ['email' => $contact['contact_email'], 'name' => $contact['contact_name'], 'footnote' => $type === 'any' ? "You get this as the contact on ticket $reference." : "You get this as a $type contact for " . $row['client_name'] . "."]; } } return count(emailQueue($recipients, "Ticket task approval required - [$reference] - " . $row['ticket_subject'], [ 'marker' => true, 'kind' => 'Approval needed', 'tone' => 'warning', 'preview' => "$by needs your approval - " . $approval['task_name'], 'title' => 'Your approval is needed', 'intro' => ["$by needs your approval for a task on ticket $reference before the work goes ahead."], 'summary' => ['Task' => (string) $approval['task_name'], 'Ticket' => $reference, 'Subject' => (string) $row['ticket_subject'], 'Status' => (string) $row['ticket_status_name']], 'button' => ['label' => 'Review and approve', 'url' => "https://$config_base_url/guest/guest_approve_ticket_task.php?task_approval_id=" . intval($approval['approval_id']) . '&url_key=' . rawurlencode((string) $approval['approval_url_key'])], 'after' => ['Want to know more first? Just reply to this email.'], 'contact' => ticketEmailContactLine(), ], ['from' => ticketEmailFrom()])); } /* * Tell someone who replied to a closed ticket by email that it is closed, so * their reply reached nobody - and how to raise a new one. $from_email raw. */ function ticketMailClosedReply($ticket_id, $from_email) { $closed = ticketRow($ticket_id); if (!$closed) { return 0; } $support = ticketEmailFrom()['email']; return count(emailQueue([['email' => (string) $from_email, 'greet' => '', 'footnote' => 'You get this because you replied to a closed ticket.']], "Action required: This ticket is already closed", [ 'kind' => 'Ticket closed', 'tone' => 'warning', 'preview' => 'Your reply did not reach us - raise a new ticket instead', 'title' => 'That ticket is closed', 'intro' => ["You replied to ticket " . ticketReference($closed) . " - \"" . $closed['ticket_subject'] . "\" - which is closed, so your reply won't reach us.", "To raise a new ticket, send a new email to $support."], 'button' => ['label' => 'Email us', 'url' => "mailto:$support"], 'contact' => ticketEmailContactLine(), ], ['from' => ticketEmailFrom()])); } /* * An SLA alert on a ticket - in the app, and by email to the SLA alert address * (Admin > SLAs, $address) and the agent it is assigned to. $track response or * resolution; $breached true once it was missed; $due when it is or was due. */ function ticketSlaAlert($ticket_id, $track, $breached, $due, $address) { $row = ticketRow($ticket_id); if (!$row) { return 0; } $ticket_id = intval($row['ticket_id']); $ticket_ref = ticketReference($row); $subject_line = ucfirst($track) . ' SLA ' . ($breached ? 'breached' : 'at risk'); // appNotify inserts what it is given - escape at the boundary appNotify("Ticket SLA", escapeSql("$subject_line - $ticket_ref - " . $row['ticket_subject']), "/agent/ticket.php?ticket_id=$ticket_id", intval($row['ticket_client_id']), $ticket_id); $recipients = []; if (filter_var($address, FILTER_VALIDATE_EMAIL)) { $recipients[] = ['email' => $address, 'greet' => '', 'footnote' => 'You get this at the SLA alert address in Admin > SLAs.']; } // The assigned agent - once, if they are also the alert address if ($agent = ticketMailAgent($row['ticket_assigned_to'])) { $recipients[] = $agent + ['footnote' => "You get this as the agent $ticket_ref is assigned to."]; } return ticketMailAgents($recipients, "$subject_line: $ticket_ref - " . $row['ticket_subject'], ticketAgentEmailContent($row, 'sla', ['track' => $track, 'breached' => $breached, 'due' => $due])); } /** Time worked as posted - HH:MM:SS, or H:MM - normalised to HH:MM:SS; nothing sensible is 00:00:00. */ function ticketTimeWorked($input) { if (preg_match('/^\s*(\d{1,3}):([0-5]?\d)(?::([0-5]?\d))?\s*$/', (string) $input, $match)) { return sprintf('%02d:%02d:%02d', intval($match[1]), intval($match[2]), intval($match[3] ?? 0)); } return '00:00:00'; } /** Statuses a reply may leave the ticket in: the active ones but New and Closed (the system sets those), plus the one it has. */ function ticketReplyStatuses($current_status) { global $mysqli; $ids = [intval($current_status)]; $sql = mysqli_query($mysqli, "SELECT ticket_status_id FROM ticket_statuses WHERE ticket_status_id NOT IN (1, 5) AND ticket_status_active = 1"); while ($row = mysqli_fetch_row($sql)) { $ids[] = intval($row[0]); } return array_values(array_unique($ids)); } /** How many of a ticket's tasks are not done yet. */ function ticketOpenTaskCount($ticket_id) { global $mysqli; return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM tasks WHERE task_ticket_id = " . intval($ticket_id) . " AND task_completed_at IS NULL"))[0]); } /** * Tickets for another page's list - open ones first, then newest - each with * its status, agent and is_open. $where is SQL on tickets; $limit 0 is all. */ function ticketRows($where, $limit = 0) { global $mysqli; $limit = intval($limit); $sql = mysqli_query($mysqli, "SELECT tickets.ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_priority, ticket_vendor_ticket_number, ticket_created_at, ticket_updated_at, ticket_resolved_at, ticket_closed_at, ticket_assigned_to, ticket_client_id, user_name, user_avatar, ticket_status_id, ticket_status_name, ticket_status_color FROM tickets LEFT JOIN users ON ticket_assigned_to = user_id LEFT JOIN ticket_statuses ON ticket_status_id = ticket_status WHERE ($where) AND ticket_archived_at IS NULL ORDER BY (ticket_resolved_at IS NULL AND ticket_closed_at IS NULL) DESC, ticket_number DESC" . ($limit ? " LIMIT $limit" : '')); $rows = []; while ($row = mysqli_fetch_assoc($sql)) { $row['is_open'] = ticketIsOpen($row); $rows[] = $row; } return $rows; } /** The SQL for "tickets about this asset" - its primary asset or one of its others. */ function ticketAssetSql($asset_id) { $asset_id = intval($asset_id); return "tickets.ticket_asset_id = $asset_id OR tickets.ticket_id IN (SELECT ticket_assets.ticket_id FROM ticket_assets WHERE ticket_assets.asset_id = $asset_id)"; } /** How many tickets match, and how many of those are open - [all, open]. */ function ticketCounts($where) { global $mysqli; $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*), COALESCE(SUM(ticket_resolved_at IS NULL AND ticket_closed_at IS NULL), 0) FROM tickets WHERE ($where) AND ticket_archived_at IS NULL")); return [intval($row[0]), intval($row[1])]; } /** * Tickets as a table - the one used by the asset, contact, site, vendor and * project pages and their quick looks. Options: 'compact' (a quick look's * table: no header, no priority), 'vendor_ref' (their reference for it, to * copy), 'client' (a Client column, for lists across clients). */ function ticketTableHtml(array $rows, $options = []) { $compact = !empty($options['compact']); $vendor_ref = !empty($options['vendor_ref']); $with_client = !empty($options['client']); $html = '
| Ticket | Subject | ' . ($vendor_ref ? 'Their reference | ' : '') . ($with_client ? 'Client | ' : '') . 'Priority | Status | Assigned | Updated |
|---|---|---|---|---|---|---|---|
| ' . escapeHtml(ticketReference($row)) . ' | ' . '' . escapeHtml($row['ticket_subject']) . ' | '; if ($vendor_ref) { $html .= '' . ($their_ref !== '' ? '' . $their_ref . '' : '-') . ' | '; } if ($with_client) { $html .= '' . escapeHtml($row['client_name'] ?? '') . ' | '; } if (!$compact) { $html .= '' . ticketPriorityHtml($row['ticket_priority'], 'detail') . ' | '; } $html .= '' . ticketStatusHtml($row['ticket_status_name'] ?? '', $row['ticket_status_color'] ?? '', 'detail') . ' | ' . '' . formatAssignee($row['ticket_assigned_to'], $row['user_name'] ?? '', $row['user_avatar'] ?? '', $is_open) . ' | ' . '' . timeAgo($last) . ' | ' . '