$parent_reference. Comment: $comment', ticket_reply_time_worked = '00:00:00', ticket_reply_type = 'Internal', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id");
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = " . TICKET_STATUS_CLOSED . ", ticket_resolved_at = COALESCE(ticket_resolved_at, NOW()), ticket_closed_at = NOW(), ticket_closed_by = $session_user_id WHERE ticket_id = $ticket_id");
syncTicketSlaClock($ticket_id);
setTicketResolutionSlaMet($ticket_id);
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = 'Ticket $child_reference was merged into this ticket with comment: $comment.
$child_subject
$child_details', ticket_reply_time_worked = '00:00:00', ticket_reply_type = 'Internal', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $parent_id");
mysqli_query($mysqli, "UPDATE tickets SET ticket_updated_at = NOW() WHERE ticket_id = $parent_id");
// Its tags go with it - not archived ones, which aren't put on more tickets
mysqli_query($mysqli, "INSERT IGNORE INTO ticket_tags (ticket_id, tag_id)
SELECT $parent_id, ticket_tags.tag_id FROM ticket_tags JOIN tags ON tags.tag_id = ticket_tags.tag_id
WHERE ticket_tags.ticket_id = $ticket_id AND tags.tag_archived_at IS NULL");
logTicketHistory($ticket_id, "$session_name merged this ticket into $parent_reference and closed it");
logTicketHistory($parent_id, "$session_name merged $child_reference into this ticket");
logAudit("Ticket", "Merge", "$session_name merged ticket $child_reference into $parent_reference", intval($child['ticket_client_id']), $ticket_id);
triggerCustomAction('ticket_merge', $ticket_id);
}
/** The ticket a merge goes into - one the agent may reach, still open. Back with an error otherwise. */
function ticketMergeTarget($ticket_id) {
$parent = ticketRow($ticket_id);
if (!$parent || !empty($parent['ticket_closed_at']) || (intval($parent['ticket_client_id']) && !hasClientAccess($parent['ticket_client_id']))) {
flashAlert('Pick an open ticket you can reach to merge into.', 'error');
redirect();
}
return $parent;
}
if (isset($_POST['add_ticket'])) {
validateCSRFToken();
enforceUserPermission('module_support', 2);
$client_id = intval($_POST['client_id'] ?? 0);
if (!$client_id || !getFieldById('clients', $client_id, 'client_name')) {
flashAlert('Pick the client this ticket is for.', 'error');
redirect();
}
enforceClientAccess();
$subject_input = trim((string) ($_POST['subject'] ?? ''));
if ($subject_input === '') {
flashAlert('A ticket needs a subject.', 'error');
redirect();
}
$subject = escapeSql(mb_substr($subject_input, 0, 500));
$details = mysqli_real_escape_string($mysqli, (string) ($_POST['details'] ?? ''));
$priority = escapeSql(ticketPriority($_POST['priority'] ?? ''));
$category_id = ticketCategoryId($_POST['category_id'] ?? 0);
$assigned_to = ticketAgentId($_POST['assigned_to'] ?? 0);
$ticket_status = $assigned_to ? TICKET_STATUS_OPEN : TICKET_STATUS_NEW;
$billable = intval($_POST['billable'] ?? 0) ? 1 : 0;
$due = ticketDueSql($_POST['due'] ?? '');
$vendor_ticket_number = escapeSql(mb_substr(trim((string) ($_POST['vendor_ticket_number'] ?? '')), 0, 255));
$ticket_template_id = intval($_POST['ticket_template_id'] ?? 0);
// What the template asks for, answered - checked before anything is saved
$fill_in_answers = null;
if ($ticket_template_id) {
$fill_in_answers = ticketFillInAnswers(ticketTemplateFillInsFor($ticket_template_id), $_POST['fill_ins'] ?? [], $fill_in_error);
if ($fill_in_answers === null) {
flashAlert($fill_in_error, 'error');
redirect();
}
}
// What it is about - the client's own only
$contact_id = ticketClientOwnedId('contact', $_POST['contact_id'] ?? 0, $client_id);
if (intval($_POST['use_primary_contact'] ?? 0)) {
$primary = mysqli_fetch_row(mysqli_query($mysqli, "SELECT contact_id FROM contacts WHERE contact_client_id = $client_id AND contact_primary = 1 AND contact_archived_at IS NULL LIMIT 1"));
$contact_id = $primary ? intval($primary[0]) : $contact_id;
}
$asset_id = ticketClientOwnedId('asset', $_POST['asset_id'] ?? 0, $client_id);
$location_id = ticketClientOwnedId('location', $_POST['location_id'] ?? 0, $client_id);
$vendor_id = ticketClientOwnedId('vendor', $_POST['vendor_id'] ?? 0, $client_id);
$project_id = ticketClientOwnedId('project', $_POST['project_id'] ?? 0, $client_id);
$other_asset_ids = ticketClientOwnedAssetIds($_POST['additional_assets'] ?? [], $client_id, $asset_id);
// 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);
$ticket_prefix = escapeSql($config_ticket_prefix);
// A key for the client's guest link to it
$url_key = randomString(32);
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$ticket_prefix', ticket_number = $ticket_number, ticket_source = 'Agent', ticket_category = $category_id,
ticket_subject = '$subject', ticket_details = '$details', ticket_priority = '$priority', ticket_billable = $billable, ticket_status = $ticket_status,
ticket_vendor_ticket_number = '$vendor_ticket_number', ticket_vendor_id = $vendor_id, ticket_location_id = $location_id, ticket_asset_id = $asset_id,
ticket_created_by = $session_user_id, ticket_assigned_to = $assigned_to, ticket_contact_id = $contact_id, ticket_url_key = '$url_key', ticket_due_at = $due,
ticket_client_id = $client_id, ticket_invoice_id = 0, ticket_project_id = $project_id");
$ticket_id = mysqli_insert_id($mysqli);
applyTicketSla($ticket_id);
// Tasks come from the editable rows in the modal, which the template pre-fills.
// Fall back to copying the template directly for a form without that section.
if (isset($_POST['tasks_submitted'])) {
foreach (parseSubmittedTasks() as $task) {
mysqli_query($mysqli, "INSERT INTO tasks SET task_name = '{$task['name']}', task_order = {$task['order']}, task_completion_estimate = {$task['estimate']}, task_ticket_id = $ticket_id");
}
}
// Store any attached files against the ticket itself
$emailable_attachments = filterEmailableAttachments(saveTicketAttachments($ticket_id, null));
ticketAddWatchers($ticket_id, ticketWatcherEmails($_POST['watchers'] ?? []));
ticketSetOtherAssets($ticket_id, $other_asset_ids);
tagSetForRecord('ticket', $ticket_id, (array) ($_POST['tags'] ?? []));
// Raised from a template - kept for the template's use, its answers kept and everything in
// brackets filled in. The form had the template's fields in it already, so nothing more is added.
if ($ticket_template_id) {
ticketTemplateFinish($ticket_id, $ticket_template_id, false, !isset($_POST['tasks_submitted']), $fill_in_answers);
}
// Raised about a service - from the service page's "Report a problem"
foreach (array_unique(array_map('intval', (array) ($_POST['service_ids'] ?? []))) as $service_id) {
$service = serviceRow($service_id);
if ($service && intval($service['service_client_id']) === $client_id && !$service['service_archived_at']) {
serviceLinkTickets($service_id, $client_id, [$ticket_id]);
logAudit("Service", "Link", "$session_name raised ticket $ticket_prefix$ticket_number about service " . escapeSql($service['service_name']), $client_id, $service_id);
}
}
// E-mail the client and the watchers
if (intval($config_ticket_client_general_notifications) === 1) {
ticketMailCreated($ticket_id, 'Ticket Created', $emailable_attachments['send']);
ticketMailColleaguesNew($ticket_id);
}
// Custom action/notif handler
triggerCustomAction('ticket_create', $ticket_id);
if ($assigned_to) {
$row = ticketRow($ticket_id);
ticketNotifyAgent($assigned_to, $row, "Ticket " . ticketReference($row) . " - Subject: " . $row['ticket_subject'] . " has been assigned to you by $session_name");
ticketMailAssigned($assigned_to, [$ticket_id], stripslashes($session_name));
}
logAudit("Ticket", "Create", "$session_name created ticket $ticket_prefix$ticket_number - $subject", $client_id, $ticket_id);
flashAlert("Ticket " . escapeHtml($config_ticket_prefix . $ticket_number) . " created");
// Tell the agent about anything too large for the mail queue to carry
if (!empty($emailable_attachments['skipped'])) {
$skipped_names = [];
foreach ($emailable_attachments['skipped'] as $skipped_attachment) {
$skipped_names[] = escapeHtml($skipped_attachment['name']);
}
flashAlert("Stored on the ticket but too large to email: " . implode(', ', $skipped_names) . "", 'error');
}
redirect("ticket.php?client_id=$client_id&ticket_id=$ticket_id");
}
if (isset($_POST['edit_ticket_fill_ins'])) {
// What the ticket's template asked for, answered or changed on the ticket
validateCSRFToken();
$row = ticketForRequest($_POST['ticket_id'] ?? 0, 2);
$ticket_id = intval($row['ticket_id']);
$reference = escapeSql(ticketReference($row));
if (!empty($row['ticket_closed_at'])) {
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " is closed - a closed ticket stays as it was.", 'error');
redirect();
}
$before = ticketFillInsStored('ticket', $ticket_id);
$answers = ticketFillInAnswers($before, $_POST['fill_ins'] ?? [], $fill_in_error, false);
if ($answers === null) {
flashAlert($fill_in_error, 'error');
redirect();
}
$changed = array_values(array_filter($answers, function ($answer) use ($before) {
foreach ($before as $old) {
if ($old['name'] === $answer['name']) {
return (string) $old['value'] !== (string) $answer['value'];
}
}
return true;
}));
if ($changed) {
ticketFillInsStore('ticket', $ticket_id, $answers);
// An answer given now goes in wherever its [Name] is still waiting
ticketPlaceholdersFill($ticket_id);
mysqli_query($mysqli, "UPDATE tickets SET ticket_updated_at = NOW() WHERE ticket_id = $ticket_id");
foreach ($changed as $answer) {
logTicketHistory($ticket_id, "$session_name " . escapeSql($answer['value'] === '' ? "cleared {$answer['name']}" : "set {$answer['name']} to " . ticketFillInText($answer)));
}
logAudit("Ticket", "Edit", "$session_name answered the fill-ins on ticket $reference", intval($row['ticket_client_id']), $ticket_id);
}
flashAlert($changed ? "Fill-ins on " . escapeHtml(ticketReference($row)) . " saved" : "Nothing changed");
redirect();
}
if (isset($_POST['edit_ticket'])) {
validateCSRFToken();
$row = ticketForRequest($_POST['ticket_id'] ?? 0, 2);
$ticket_id = intval($row['ticket_id']);
$reference = escapeSql(ticketReference($row));
if (!empty($row['ticket_closed_at'])) {
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " is closed - a closed ticket stays as it was.", 'error');
redirect();
}
$subject_input = trim((string) ($_POST['subject'] ?? ''));
if ($subject_input === '') {
flashAlert('A ticket needs a subject.', 'error');
redirect();
}
$subject = escapeSql(mb_substr($subject_input, 0, 500));
$details = mysqli_real_escape_string($mysqli, (string) ($_POST['details'] ?? ''));
$priority = ticketPriority($_POST['priority'] ?? $row['ticket_priority']);
$priority_sql = escapeSql($priority);
$category_id = ticketCategoryId($_POST['category_id'] ?? 0);
$assigned_to = ticketAgentId($_POST['assigned_to'] ?? 0);
$billable = intval($_POST['billable'] ?? 0) ? 1 : 0;
$due = ticketDueSql($_POST['due'] ?? '');
$notify = intval($_POST['contact_notify'] ?? 0);
// A ticket that gets an agent is no longer New
$ticket_status = intval($row['ticket_status']);
if ($ticket_status === TICKET_STATUS_NEW && $assigned_to) {
$ticket_status = TICKET_STATUS_OPEN;
}
$set = "ticket_category = $category_id, ticket_subject = '$subject', ticket_priority = '$priority_sql', ticket_billable = $billable, ticket_details = '$details',
ticket_due_at = $due, ticket_assigned_to = $assigned_to, ticket_status = $ticket_status";
// The contact and what it is linked to - each only from a form that showed it
// (a ticket with no client has no contact, asset or site), and only the client's own
$contact_id = intval($row['ticket_contact_id']);
if (isset($_POST['contact_id'])) {
$contact_id = ticketClientOwnedId('contact', $_POST['contact_id'], $client_id);
$set .= ", ticket_contact_id = $contact_id";
}
$asset_posted = isset($_POST['asset_id']);
if ($asset_posted) {
$asset_id = ticketClientOwnedId('asset', $_POST['asset_id'], $client_id);
$set .= ", ticket_asset_id = $asset_id";
}
if (isset($_POST['location_id'])) {
$set .= ", ticket_location_id = " . ticketClientOwnedId('location', $_POST['location_id'], $client_id);
}
if (isset($_POST['vendor_id'])) {
$set .= ", ticket_vendor_id = " . ticketClientOwnedId('vendor', $_POST['vendor_id'], $client_id);
}
if (isset($_POST['vendor_ticket_number'])) {
$set .= ", ticket_vendor_ticket_number = '" . escapeSql(mb_substr(trim((string) $_POST['vendor_ticket_number']), 0, 255)) . "'";
}
if (isset($_POST['project_id'])) {
$set .= ", ticket_project_id = " . ticketClientOwnedId('project', $_POST['project_id'], $client_id);
}
mysqli_query($mysqli, "UPDATE tickets SET $set WHERE ticket_id = $ticket_id");
applyTicketSla($ticket_id);
if ($ticket_status !== intval($row['ticket_status'])) {
syncTicketSlaClock($ticket_id);
}
// An empty multi-select posts nothing, so the other assets go with the asset field
if ($asset_posted) {
ticketSetOtherAssets($ticket_id, ticketClientOwnedAssetIds($_POST['additional_assets'] ?? [], $client_id, $asset_id));
}
// ...and the tags with their marker
if (isset($_POST['tags_form'])) {
$tags_before = tagIdsForRecord('ticket', $ticket_id);
$tags_after = tagSetForRecord('ticket', $ticket_id, (array) ($_POST['tags'] ?? []));
$tags_text = tagChangeText($tags_before, $tags_after);
if ($tags_text !== '') {
logTicketHistory($ticket_id, escapeSql(stripslashes((string) $session_name) . " $tags_text"));
}
}
// The trail for what changed - the same entries the dedicated pickers write
if ($row['ticket_priority'] !== $priority) {
logTicketHistory($ticket_id, "$session_name changed priority from " . escapeSql($row['ticket_priority']) . " to $priority_sql");
}
if (intval($row['ticket_assigned_to']) !== $assigned_to) {
if ($assigned_to) {
logTicketHistory($ticket_id, "$session_name assigned the ticket to " . escapeSql(getFieldById('users', $assigned_to, 'user_name')));
ticketNotifyAgent($assigned_to, $row, "Ticket " . ticketReference($row) . " - Subject: " . $subject_input . " has been assigned to you by $session_name");
ticketMailAssigned($assigned_to, [$ticket_id], stripslashes($session_name));
} else {
logTicketHistory($ticket_id, "$session_name unassigned the ticket");
}
}
if (intval($row['ticket_contact_id']) !== $contact_id) {
$contact_name = $contact_id ? escapeSql(getFieldById('contacts', $contact_id, 'contact_name')) : 'No one';
logTicketHistory($ticket_id, "$session_name changed the contact from " . ($row['contact_name'] ? escapeSql($row['contact_name']) : 'No one') . " to $contact_name");
}
// Let the contact know about it, when asked
if ($notify && $contact_id && empty($row['ticket_closed_at'])) {
ticketMailCreated($ticket_id, 'Ticket Created -', [], false);
}
// Custom action/notif handler
triggerCustomAction('ticket_update', $ticket_id);
logAudit("Ticket", "Edit", "$session_name edited ticket $reference", $client_id, $ticket_id);
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " updated");
redirect();
}
if (isset($_POST['edit_ticket_sla'])) {
validateCSRFToken();
$row = ticketForRequest($_POST['ticket_id'] ?? 0, 2);
$ticket_id = intval($row['ticket_id']);
$sla_id = intval($_POST['sla_id'] ?? 0);
// No SLA, the one it has, or one that is still in use
$sla_name = 'None';
if ($sla_id) {
$sla = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT sla_name FROM slas WHERE sla_id = $sla_id AND (sla_archived_at IS NULL OR sla_id = " . intval($row['ticket_sla_id']) . ")"));
if (!$sla) {
flashAlert('That SLA plan is not available.', 'error');
redirect();
}
$sla_name = $sla['sla_name'];
}
$original_sla_name = intval($row['ticket_sla_id']) ? (string) getFieldById('slas', $row['ticket_sla_id'], 'sla_name') : 'None';
// Pin the ticket to the chosen SLA and recompute its targets
applyTicketSla($ticket_id, $sla_id);
logTicketHistory($ticket_id, "$session_name changed SLA from " . escapeSql($original_sla_name) . " to " . escapeSql($sla_name));
logAudit("Ticket", "Edit", "$session_name changed SLA from " . escapeSql($original_sla_name) . " to " . escapeSql($sla_name) . " for ticket " . escapeSql(ticketReference($row)), $client_id, $ticket_id);
triggerCustomAction('ticket_update', $ticket_id);
flashAlert("SLA updated from " . escapeHtml($original_sla_name) . " to " . escapeHtml($sla_name) . "");
redirect();
}
if (isset($_POST['edit_ticket_contact'])) {
validateCSRFToken();
$row = ticketForRequest($_POST['ticket_id'] ?? 0, 2);
$ticket_id = intval($row['ticket_id']);
$contact_id = ticketClientOwnedId('contact', $_POST['contact'] ?? 0, $client_id);
$notify = intval($_POST['contact_notify'] ?? 0);
$original_contact_name = $row['contact_name'] ? $row['contact_name'] : 'No one';
$contact_name = $contact_id ? (string) getFieldById('contacts', $contact_id, 'contact_name') : 'No one';
mysqli_query($mysqli, "UPDATE tickets SET ticket_contact_id = $contact_id WHERE ticket_id = $ticket_id");
// Let the new contact know about it, when asked
if ($notify && $contact_id && empty($row['ticket_closed_at'])) {
ticketMailCreated($ticket_id, 'Ticket Created -', [], false);
}
triggerCustomAction('ticket_update', $ticket_id);
logTicketHistory($ticket_id, "$session_name changed the contact from " . escapeSql($original_contact_name) . " to " . escapeSql($contact_name));
logAudit("Ticket", "Edit", "$session_name changed the contact from " . escapeSql($original_contact_name) . " to " . escapeSql($contact_name) . " for ticket " . escapeSql(ticketReference($row)), $client_id, $ticket_id);
flashAlert("Contact changed from " . escapeHtml($original_contact_name) . " to " . escapeHtml($contact_name) . "");
redirect();
}
if (isset($_POST['edit_ticket_project'])) {
validateCSRFToken();
$row = ticketForRequest($_POST['ticket_id'] ?? 0, 2);
$ticket_id = intval($row['ticket_id']);
$project_id = ticketClientOwnedId('project', $_POST['project'] ?? 0, $client_id);
$project_name = $project_id ? (string) getFieldById('projects', $project_id, 'project_name') : '';
$reference = escapeSql(ticketReference($row));
mysqli_query($mysqli, "UPDATE tickets SET ticket_project_id = $project_id WHERE ticket_id = $ticket_id");
if ($project_id) {
logTicketHistory($ticket_id, "$session_name added the ticket to project " . escapeSql($project_name));
logAudit("Ticket", "Edit", "$session_name set ticket $reference project to " . escapeSql($project_name), $client_id, $ticket_id);
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " is now part of project " . escapeHtml($project_name) . "");
} else {
logTicketHistory($ticket_id, "$session_name took the ticket out of its project");
logAudit("Ticket", "Edit", "$session_name took ticket $reference out of its project", $client_id, $ticket_id);
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " is no longer part of a project");
}
redirect();
}
if (isset($_POST['add_ticket_watcher'])) {
validateCSRFToken();
$row = ticketForRequest($_POST['ticket_id'] ?? 0, 2);
$ticket_id = intval($row['ticket_id']);
$notify = intval($_POST['watcher_notify'] ?? 0);
$reference = escapeSql(ticketReference($row));
$added = ticketAddWatchers($ticket_id, ticketWatcherEmails($_POST['watcher_email'] ?? ''));
// Let each new watcher know, when asked
if ($added && $notify) {
ticketMailWatchersAdded($ticket_id, $added, stripslashes($session_name));
}
foreach ($added as $watcher_email) {
logTicketHistory($ticket_id, "$session_name added " . escapeSql($watcher_email) . " as a watcher");
logAudit("Ticket", "Edit", "$session_name added " . escapeSql($watcher_email) . " as a watcher for ticket $reference", $client_id, $ticket_id);
}
$count = count($added);
flashAlert($count ? "Added $count watcher" . ($count === 1 ? '' : 's') : 'No new watchers - those addresses are watching already, or are not email addresses', $count ? 'success' : 'error');
redirect();
}
if (isset($_GET['delete_ticket_watcher'])) {
validateCSRFToken();
$watcher_id = intval($_GET['delete_ticket_watcher']);
$watcher = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT watcher_email, watcher_ticket_id FROM ticket_watchers WHERE watcher_id = $watcher_id"));
if (!$watcher) {
flashAlert('That watcher was already removed.', 'error');
redirect();
}
$row = ticketForRequest($watcher['watcher_ticket_id'], 2);
$ticket_id = intval($row['ticket_id']);
$watcher_email = escapeSql($watcher['watcher_email']);
mysqli_query($mysqli, "DELETE FROM ticket_watchers WHERE watcher_id = $watcher_id");
logTicketHistory($ticket_id, "$session_name removed $watcher_email as a watcher");
logAudit("Ticket", "Edit", "$session_name removed $watcher_email as a watcher for ticket " . escapeSql(ticketReference($row)), $client_id, $ticket_id);
flashAlert("Removed ticket watcher " . escapeHtml($watcher['watcher_email']) . "", 'error');
redirect();
}
if (isset($_GET['delete_ticket_additional_asset'])) {
validateCSRFToken();
$row = ticketForRequest($_GET['ticket_id'] ?? 0, 2);
$ticket_id = intval($row['ticket_id']);
$asset_id = intval($_GET['delete_ticket_additional_asset']);
$asset_name = (string) getFieldById('assets', $asset_id, 'asset_name');
mysqli_query($mysqli, "DELETE FROM ticket_assets WHERE ticket_id = $ticket_id AND asset_id = $asset_id");
// Named only when it was on this ticket - any other id would say another client's asset name back
if (mysqli_affected_rows($mysqli)) {
logTicketHistory($ticket_id, "$session_name removed additional asset " . escapeSql($asset_name));
logAudit("Ticket", "Edit", "$session_name removed asset " . escapeSql($asset_name) . " from ticket " . escapeSql(ticketReference($row)), $client_id, $ticket_id);
flashAlert("Removed asset " . escapeHtml($asset_name) . " from the ticket", 'error');
} else {
flashAlert("That asset isn't on this ticket any more.", 'error');
}
redirect();
}
if (isset($_POST['edit_ticket_asset'])) {
validateCSRFToken();
$row = ticketForRequest($_POST['ticket_id'] ?? 0, 2);
$ticket_id = intval($row['ticket_id']);
$asset_id = ticketClientOwnedId('asset', $_POST['asset'] ?? 0, $client_id);
$reference = escapeSql(ticketReference($row));
mysqli_query($mysqli, "UPDATE tickets SET ticket_asset_id = $asset_id WHERE ticket_id = $ticket_id");
// The others, when the form carried them
if (isset($_POST['additional_assets']) || isset($_POST['additional_assets_form'])) {
ticketSetOtherAssets($ticket_id, ticketClientOwnedAssetIds($_POST['additional_assets'] ?? [], $client_id, $asset_id));
} else {
// The primary asset is never also one of the others
mysqli_query($mysqli, "DELETE FROM ticket_assets WHERE ticket_id = $ticket_id AND asset_id = $asset_id");
}
$asset_name = $asset_id ? (string) getFieldById('assets', $asset_id, 'asset_name') : '';
if ($asset_id !== intval($row['ticket_asset_id'])) {
logTicketHistory($ticket_id, $asset_id ? "$session_name set the asset to " . escapeSql($asset_name) : "$session_name removed the asset");
}
logAudit("Ticket", "Edit", "$session_name " . ($asset_id ? "changed asset to " . escapeSql($asset_name) : "removed the asset") . " for ticket $reference", $client_id, $ticket_id);
flashAlert($asset_id ? "Ticket " . escapeHtml(ticketReference($row)) . " asset set to " . escapeHtml($asset_name) . "" : "Removed the asset from ticket " . escapeHtml(ticketReference($row)) . "");
redirect();
}
if (isset($_POST['edit_ticket_vendor'])) {
validateCSRFToken();
$row = ticketForRequest($_POST['ticket_id'] ?? 0, 2);
$ticket_id = intval($row['ticket_id']);
$reference = escapeSql(ticketReference($row));
// Only the ticket's own client's vendors
$vendor_id = ticketClientOwnedId('vendor', $_POST['vendor'] ?? 0, $client_id);
mysqli_query($mysqli, "UPDATE tickets SET ticket_vendor_id = $vendor_id WHERE ticket_id = $ticket_id");
// The vendor modal carries the vendor's own ticket number too. Only touched when the
// field was posted, so anything else that submits this action cannot blank it by omission
if (isset($_POST['vendor_ticket_number'])) {
$vendor_ticket_number = escapeSql(mb_substr(trim((string) $_POST['vendor_ticket_number']), 0, 255));
mysqli_query($mysqli, "UPDATE tickets SET ticket_vendor_ticket_number = '$vendor_ticket_number' WHERE ticket_id = $ticket_id");
}
$vendor_name = $vendor_id ? (string) getFieldById('vendors', $vendor_id, 'vendor_name') : '';
if ($vendor_id) {
if ($vendor_id !== intval($row['ticket_vendor_id'])) {
logTicketHistory($ticket_id, "$session_name set the vendor to " . escapeSql($vendor_name));
}
logAudit("Ticket", "Edit", "$session_name set vendor to " . escapeSql($vendor_name) . " for ticket $reference", $client_id, $ticket_id);
flashAlert("Set vendor to " . escapeHtml($vendor_name) . " for ticket " . escapeHtml(ticketReference($row)) . "");
} else {
if (intval($row['ticket_vendor_id'])) {
logTicketHistory($ticket_id, "$session_name removed the vendor");
}
logAudit("Ticket", "Edit", "$session_name removed the vendor from ticket $reference", $client_id, $ticket_id);
flashAlert("Removed the vendor from ticket " . escapeHtml(ticketReference($row)) . "");
}
redirect();
}
// Closed tickets are the record of work done - kept, not deleted
if (isset($_GET['delete_ticket'])) {
validateCSRFToken();
$row = ticketForRequest($_GET['delete_ticket'], 3);
$ticket_id = intval($row['ticket_id']);
$reference = escapeSql(ticketReference($row));
if (!empty($row['ticket_closed_at'])) {
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " is closed - closed tickets are kept, not deleted.", 'error');
redirect();
}
ticketDeleteForGood($ticket_id);
logAudit("Ticket", "Delete", "$session_name deleted $reference - " . escapeSql($row['ticket_subject']) . " along with all replies", $client_id);
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " along with all replies deleted", 'error');
triggerCustomAction('ticket_delete', $ticket_id);
redirect("tickets.php");
}
if (isset($_POST['bulk_delete_tickets'])) {
validateCSRFToken();
$count = $kept = 0;
foreach (ticketsForBulk(3) as $ticket_id => $row) {
if (!empty($row['ticket_closed_at'])) {
$kept++;
continue;
}
ticketDeleteForGood($ticket_id);
logAudit("Ticket", "Delete", "$session_name deleted ticket " . escapeSql(ticketReference($row)) . " - " . escapeSql($row['ticket_subject']), intval($row['ticket_client_id']));
triggerCustomAction('ticket_delete', $ticket_id);
$count++;
}
logAudit("Ticket", "Bulk Delete", "$session_name deleted $count ticket(s)");
flashAlert("Deleted $count ticket" . ($count === 1 ? '' : 's') . ($kept ? ", kept $kept closed - closed tickets are not deleted" : ''), 'error');
redirect();
}
if (isset($_POST['bulk_assign_ticket'])) {
validateCSRFToken();
$assign_to = ticketAgentId($_POST['assign_to'] ?? 0);
if (intval($_POST['assign_to'] ?? 0) && !$assign_to) {
flashAlert('Pick an active agent.', 'error');
redirect();
}
$agent_name = $assign_to ? (string) getFieldById('users', $assign_to, 'user_name') : 'No One';
$agent_name_sql = escapeSql($agent_name);
$count = $skipped = 0;
$assigned_ids = [];
foreach (ticketsForBulk(2) as $ticket_id => $row) {
// A closed ticket keeps who closed it
if (!empty($row['ticket_closed_at'])) {
$skipped++;
continue;
}
$ticket_status = intval($row['ticket_status']);
if ($ticket_status === TICKET_STATUS_NEW && $assign_to) {
$ticket_status = TICKET_STATUS_OPEN;
}
$reply = $assign_to ? "Ticket re-assigned to $agent_name_sql." : "Ticket unassigned, pending re-assignment.";
mysqli_query($mysqli, "UPDATE tickets SET ticket_assigned_to = $assign_to, ticket_status = $ticket_status WHERE ticket_id = $ticket_id");
syncTicketSlaClock($ticket_id);
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$reply', ticket_reply_type = 'Internal', ticket_reply_time_worked = '00:00:00', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id");
logTicketHistory($ticket_id, "$session_name assigned the ticket to $agent_name_sql");
logAudit("Ticket", "Edit", "$session_name reassigned ticket " . escapeSql(ticketReference($row)) . " to $agent_name_sql", intval($row['ticket_client_id']), $ticket_id);
triggerCustomAction('ticket_assign', $ticket_id);
$assigned_ids[] = $ticket_id;
$count++;
}
// One notification and one email for the lot
if ($count && $assign_to && $assign_to != $session_user_id) {
mysqli_query($mysqli, "INSERT INTO notifications SET notification_type = 'Ticket', notification = '$count ticket" . ($count === 1 ? ' has' : 's have') . " been assigned to you by $session_name', notification_action = '/agent/tickets.php?assigned=$assign_to', notification_user_id = $assign_to");
ticketMailAssigned($assign_to, $assigned_ids, stripslashes($session_name));
}
flashAlert("Assigned $count ticket" . ($count === 1 ? '' : 's') . " to " . escapeHtml($agent_name) . "" . ($skipped ? ", $skipped closed left as they were" : ''));
redirect();
}
if (isset($_POST['bulk_edit_ticket_priority'])) {
validateCSRFToken();
$priority = $_POST['bulk_priority'] ?? '';
if (!in_array($priority, TICKET_PRIORITIES, true)) {
flashAlert('That is not a priority.', 'error');
redirect();
}
// The same change the priority picker makes - history, SLA, audit and the custom action
$count = 0;
foreach (ticketsForBulk(2) as $ticket_id => $row) {
[$ok] = ticketChangePriority($ticket_id, $priority);
$count += $ok ? 1 : 0;
}
logAudit("Ticket", "Bulk Edit", "$session_name set the priority of $count ticket(s) to $priority");
flashAlert("Priority set to $priority on $count ticket" . ($count === 1 ? '' : 's'));
redirect();
}
if (isset($_POST['bulk_edit_ticket_category'])) {
validateCSRFToken();
$category_id = ticketCategoryId($_POST['bulk_category'] ?? 0);
$category_name = $category_id ? (string) getFieldById('categories', $category_id, 'category_name') : 'Not categorized';
$count = 0;
foreach (ticketsForBulk(2) as $ticket_id => $row) {
$previous = intval($row['ticket_category']) ? (string) getFieldById('categories', intval($row['ticket_category']), 'category_name') : 'Not categorized';
mysqli_query($mysqli, "UPDATE tickets SET ticket_category = $category_id WHERE ticket_id = $ticket_id");
if (intval($row['ticket_category']) !== $category_id) {
logTicketHistory($ticket_id, "$session_name changed the category from " . escapeSql($previous) . " to " . escapeSql($category_name));
}
logAudit("Ticket", "Edit", "$session_name updated the category on ticket " . escapeSql(ticketReference($row)) . " from " . escapeSql($previous) . " to " . escapeSql($category_name), intval($row['ticket_client_id']), $ticket_id);
triggerCustomAction('ticket_update', $ticket_id);
$count++;
}
logAudit("Ticket", "Bulk Edit", "$session_name set the category of $count ticket(s) to " . escapeSql($category_name));
flashAlert("Category set to " . escapeHtml($category_name) . " on $count ticket" . ($count === 1 ? '' : 's'));
redirect();
}
if (isset($_POST['bulk_merge_tickets'])) {
validateCSRFToken();
enforceUserPermission('module_support', 2);
$parent = ticketMergeTarget($_POST['merge_into_ticket_id'] ?? 0);
$comment = escapeSql($_POST['merge_comment'] ?? '');
// A ticket only merges into one of its own client's - another client's would see the conversation
$count = $skipped = $other_client = 0;
foreach (ticketsForBulk(2) as $ticket_id => $row) {
if ($ticket_id === intval($parent['ticket_id']) || !empty($row['ticket_closed_at'])) {
$skipped++;
continue;
}
if (intval($row['ticket_client_id']) !== intval($parent['ticket_client_id'])) {
$other_client++;
continue;
}
ticketMergeInto($row, $parent, $comment);
$count++;
}
flashAlert("$count ticket" . ($count === 1 ? '' : 's') . " merged into " . escapeHtml(ticketReference($parent)) . ""
. ($skipped ? ", $skipped left - closed already, or the ticket itself" : '')
. ($other_client ? ", $other_client left - another client's" : ''), $count ? 'success' : 'error');
redirect();
}
if (isset($_POST['bulk_resolve_tickets'])) {
validateCSRFToken();
$details = mysqli_real_escape_string($mysqli, (string) ($_POST['bulk_details'] ?? ''));
$time_worked = escapeSql(ticketTimeWorked($_POST['time'] ?? ''));
$private_note = intval($_POST['bulk_private_note'] ?? 0);
$reply_type = $private_note ? 'Internal' : 'Public';
$count = $skipped_tasks = $skipped_done = 0;
foreach (ticketsForBulk(2) as $ticket_id => $row) {
if (!ticketIsOpen($row)) {
$skipped_done++;
continue;
}
// Tasks have to be finished first
if (ticketOpenTaskCount($ticket_id)) {
$skipped_tasks++;
continue;
}
ticketResolve($row);
$resolve_reply_id = 0;
if ($details !== '' || $time_worked !== '00:00:00') {
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$details', ticket_reply_type = '$reply_type', ticket_reply_time_worked = '$time_worked', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id");
$resolve_reply_id = mysqli_insert_id($mysqli);
}
// Client notification email - it carries the details, so their reply keeps who it went to
if (intval($config_ticket_client_general_notifications) === 1 && !$private_note) {
ticketMailResolved($ticket_id, (string) ($_POST['bulk_details'] ?? ''), $resolve_reply_id);
} else {
ticketReplyEmailed($resolve_reply_id, false);
}
$count++;
}
flashAlert("Resolved $count ticket" . ($count === 1 ? '' : 's')
. ($skipped_tasks ? ", $skipped_tasks not resolved - they have open tasks" : '')
. ($skipped_done ? ", $skipped_done resolved or closed already" : ''), $skipped_tasks ? 'warning' : 'success');
redirect();
}
if (isset($_POST['bulk_ticket_reply'])) {
validateCSRFToken();
// The placeholders are filled in for each ticket, below
$reply_raw = (string) ($_POST['bulk_reply_details'] ?? '');
$canned_response_ids = (array) ($_POST['canned_response_ids'] ?? []);
$canned_response_file_ids = (array) ($_POST['canned_response_file_ids'] ?? []);
$status_input = intval($_POST['bulk_status'] ?? 0);
$time_worked = escapeSql(ticketTimeWorked($_POST['time'] ?? ''));
$private_note = intval($_POST['bulk_private_reply'] ?? 0);
$reply_type = $private_note ? 'Internal' : 'Public';
$count = $skipped = 0;
foreach (ticketsForBulk(2) as $ticket_id => $row) {
// A closed ticket takes no more replies
if (!empty($row['ticket_closed_at'])) {
$skipped++;
continue;
}
$original_status = intval($row['ticket_status']);
$ticket_status = in_array($status_input, ticketReplyStatuses($original_status), true) ? $status_input : $original_status;
// Resolving waits for the tasks
if ($ticket_status === TICKET_STATUS_RESOLVED && $original_status !== TICKET_STATUS_RESOLVED && ticketOpenTaskCount($ticket_id)) {
$ticket_status = $original_status;
}
$reference = escapeSql(ticketReference($row));
$subject = escapeSql($row['ticket_subject']);
// Mark FR time if required - internal notes don't count as a response
if (empty($row['ticket_first_response_at']) && $reply_type === 'Public') {
setTicketFirstResponse($ticket_id);
}
// No @mentions here - one note to many tickets tells nobody, so any are left as text
$reply_text = ticketMentionsFromHtml(cannedResponsePlaceholders($reply_raw, $ticket_id, stripslashes($session_name)), 0, false)[0];
$reply = mysqli_real_escape_string($mysqli, $reply_text);
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$reply', ticket_reply_time_worked = '$time_worked', ticket_reply_type = '$reply_type', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id");
$bulk_reply_id = mysqli_insert_id($mysqli);
cannedResponseRecordUses($canned_response_ids, $ticket_id, $session_user_id);
// The files of the canned responses that went into it, on each ticket and in its email
$bulk_attachments = filterEmailableAttachments(cannedResponseAttachFiles($canned_response_file_ids, $ticket_id, $bulk_reply_id, $session_user_id));
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = $ticket_status, ticket_updated_at = NOW() WHERE ticket_id = $ticket_id");
syncTicketSlaClock($ticket_id);
if ($ticket_status !== $original_status && $ticket_status !== TICKET_STATUS_RESOLVED) {
logTicketHistory($ticket_id, "$session_name set the status to " . escapeSql(getTicketStatusName($ticket_status)));
}
logAudit("Ticket", "Reply", "$session_name replied to ticket $reference - $subject and was a $reply_type reply", intval($row['ticket_client_id']), $ticket_id);
triggerCustomAction($reply_type === 'Internal' ? 'ticket_reply_agent_internal' : 'reply_reply_agent_public', $ticket_id);
// Resolve the ticket, if it is actually moving into Resolved
if ($ticket_status === TICKET_STATUS_RESOLVED && $original_status !== TICKET_STATUS_RESOLVED) {
mysqli_query($mysqli, "UPDATE tickets SET ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id");
setTicketResolutionSlaMet($ticket_id);
logTicketHistory($ticket_id, "$session_name resolved the ticket");
logAudit("Ticket", "Resolve", "$session_name resolved ticket $reference", intval($row['ticket_client_id']), $ticket_id);
triggerCustomAction('ticket_resolve', $ticket_id);
}
// Email the client and watchers about a public update - the reply keeps who it went to
if (!$private_note) {
ticketMailReply($ticket_id, $reply_text, stripslashes($session_name), false, $bulk_attachments['send'], $bulk_reply_id);
} else {
ticketReplyEmailed($bulk_reply_id, false);
}
// Tell the agent it is assigned to, and whoever opened it
ticketNotifyAgent($row['ticket_assigned_to'], $row, "$session_name updated Ticket " . ticketReference($row) . " - Subject: " . $row['ticket_subject'] . " that is assigned to you");
if (intval($row['ticket_created_by']) !== intval($row['ticket_assigned_to'])) {
ticketNotifyAgent($row['ticket_created_by'], $row, "$session_name updated Ticket " . ticketReference($row) . " - Subject: " . $row['ticket_subject'] . " that you opened");
}
$count++;
}
flashAlert("Updated $count ticket" . ($count === 1 ? '' : 's') . ($skipped ? ", $skipped closed left as they were" : ''));
redirect();
}
if (isset($_POST['bulk_add_ticket_project'])) {
validateCSRFToken();
enforceUserPermission('module_support', 2);
$project_input = intval($_POST['project_id'] ?? 0);
$project_name = $project_input ? (string) getFieldById('projects', $project_input, 'project_name') : '';
$count = $skipped = 0;
foreach (ticketsForBulk(2) as $ticket_id => $row) {
// The project has to be the ticket's client's, or an internal one
$project_id = ticketClientOwnedId('project', $project_input, $row['ticket_client_id']);
if ($project_input && !$project_id) {
$skipped++;
continue;
}
mysqli_query($mysqli, "UPDATE tickets SET ticket_project_id = $project_id WHERE ticket_id = $ticket_id");
logTicketHistory($ticket_id, $project_id ? "$session_name added the ticket to project " . escapeSql($project_name) : "$session_name took the ticket out of its project");
logAudit("Ticket", "Edit", "$session_name " . ($project_id ? "added ticket " . escapeSql(ticketReference($row)) . " to project " . escapeSql($project_name) : "took ticket " . escapeSql(ticketReference($row)) . " out of its project"), intval($row['ticket_client_id']), $ticket_id);
$count++;
}
// The project is named once a ticket went into it - one that fits none of them may be a client this agent can't see
flashAlert(($project_input ? "$count ticket" . ($count === 1 ? '' : 's') . " added to " . ($count ? "project " . escapeHtml($project_name) . "" : 'the project') : "$count ticket" . ($count === 1 ? '' : 's') . " taken out of their project")
. ($skipped ? ", $skipped skipped - the project is another client's" : ''));
redirect();
}
// New ticket for each asset picked on the assets list
if (isset($_POST['bulk_add_asset_ticket'])) {
validateCSRFToken();
enforceUserPermission('module_support', 2);
$assigned_to = ticketAgentId($_POST['bulk_assigned_to'] ?? 0);
$ticket_status = $assigned_to ? TICKET_STATUS_OPEN : TICKET_STATUS_NEW;
$subject_input = trim((string) ($_POST['bulk_subject'] ?? ''));
$priority = escapeSql(ticketPriority($_POST['bulk_priority'] ?? ''));
$category_id = ticketCategoryId($_POST['bulk_category'] ?? 0);
$details = mysqli_real_escape_string($mysqli, (string) ($_POST['bulk_details'] ?? ''));
$project_input = intval($_POST['bulk_project'] ?? 0);
$ticket_template_id = intval($_POST['bulk_ticket_template_id'] ?? 0);
$billable = intval($_POST['bulk_billable'] ?? 0) ? 1 : 0;
$ticket_prefix = escapeSql($config_ticket_prefix);
// A template fills in what was left empty, and what it asks for is answered once for them all
$template = ticketTemplateRow($ticket_template_id);
$fill_in_answers = null;
if ($template) {
$subject_input = $subject_input !== '' ? $subject_input : (string) $template['ticket_template_subject'];
$details = $details !== '' ? $details : mysqli_real_escape_string($mysqli, (string) $template['ticket_template_details']);
$fill_in_answers = ticketFillInAnswers(ticketTemplateFillInsFor($ticket_template_id), $_POST['fill_ins'] ?? [], $fill_in_error);
if ($fill_in_answers === null) {
flashAlert($fill_in_error, 'error');
redirect();
}
}
$ticket_ids = [];
foreach (array_unique(array_map('intval', (array) ($_POST['asset_ids'] ?? []))) as $asset_id) {
$asset = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT asset_client_id, asset_name, asset_contact_id, asset_location_id FROM assets WHERE asset_id = $asset_id"));
if (!$asset) {
continue;
}
$client_id = intval($asset['asset_client_id']);
enforceClientAccess();
// The person using it and where it is
$contact_id = ticketClientOwnedId('contact', $asset['asset_contact_id'], $client_id);
$location_id = ticketClientOwnedId('location', $asset['asset_location_id'], $client_id);
$project_id = ticketClientOwnedId('project', $project_input, $client_id);
// The asset's name goes in front - unless the subject says where, with [Asset]
$subject = escapeSql(mb_substr(stripos($subject_input, '[asset]') !== false ? $subject_input : $asset['asset_name'] . ' - ' . $subject_input, 0, 500));
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);
$url_key = randomString(32);
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$ticket_prefix', ticket_number = $ticket_number, ticket_source = 'Agent', ticket_category = $category_id,
ticket_subject = '$subject', ticket_details = '$details', ticket_priority = '$priority', ticket_billable = $billable, ticket_status = $ticket_status,
ticket_asset_id = $asset_id, ticket_contact_id = $contact_id, ticket_location_id = $location_id, ticket_created_by = $session_user_id,
ticket_assigned_to = $assigned_to, ticket_url_key = '$url_key', ticket_client_id = $client_id, ticket_project_id = $project_id");
$ticket_id = mysqli_insert_id($mysqli);
applyTicketSla($ticket_id);
// Tasks from the modal, then the template's - its tags and due time too, and [Asset] filled in
foreach ((array) ($_POST['tasks'] ?? []) as $task) {
$task_name = escapeSql(mb_substr(trim((string) $task), 0, 255));
if ($task_name !== '') {
mysqli_query($mysqli, "INSERT INTO tasks SET task_name = '$task_name', task_ticket_id = $ticket_id");
}
}
if ($template) {
ticketTemplateFinish($ticket_id, $ticket_template_id, true, true, $fill_in_answers);
}
logAudit("Ticket", "Create", "$session_name created ticket $ticket_prefix$ticket_number - $subject", $client_id, $ticket_id);
triggerCustomAction('ticket_create', $ticket_id);
$ticket_ids[] = $ticket_id;
}
$count = count($ticket_ids);
// The agent they went to hears once, about all of them
if ($ticket_ids && $assigned_to) {
ticketMailAssigned($assigned_to, $ticket_ids, stripslashes($session_name));
}
logAudit("Ticket", "Bulk Create", "$session_name created $count ticket(s) for the assets picked");
flashAlert("Created $count ticket" . ($count === 1 ? '' : 's') . " for the assets picked");
redirect();
}
if (isset($_POST['add_ticket_reply'])) {
validateCSRFToken();
$row = ticketForRequest($_POST['ticket_id'] ?? 0, 2);
$ticket_id = intval($row['ticket_id']);
$reference = escapeSql(ticketReference($row));
$subject = escapeSql($row['ticket_subject']);
if (!empty($row['ticket_closed_at'])) {
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " is closed - start a new ticket instead.", 'error');
redirect();
}
$ticket_reply = (string) ($_POST['ticket_reply'] ?? ''); // SQL escaped below
$original_ticket_status = intval($row['ticket_status']);
$ticket_status = intval($_POST['status'] ?? $original_ticket_status);
// Only a status a reply may set; Resolved waits for the tasks
if (!in_array($ticket_status, ticketReplyStatuses($original_ticket_status), true)) {
$ticket_status = $original_ticket_status;
}
if ($ticket_status === TICKET_STATUS_RESOLVED && $original_ticket_status !== TICKET_STATUS_RESOLVED && ticketOpenTaskCount($ticket_id)) {
flashAlert('Finish the open tasks before resolving this ticket.', 'error');
$ticket_status = $original_ticket_status;
}
// Time tracking, inputs & combine into string
$ticket_reply_time_worked = escapeSql(sprintf("%02d:%02d:%02d", max(0, intval($_POST['hours'] ?? 0)), max(0, min(59, intval($_POST['minutes'] ?? 0))), max(0, min(59, intval($_POST['seconds'] ?? 0)))));
// Who sees it: internal note, public note, or public and emailed - or texted (functions/sms.php)
$send_email = 0;
$send_text = 0;
$ticket_reply_id = 0;
$public_reply_type = intval($_POST['public_reply_type'] ?? 0);
$ticket_reply_type = $public_reply_type ? 'Public' : 'Internal';
if ($public_reply_type === 2) {
$send_email = 1;
} elseif ($public_reply_type === 3) {
$send_text = 1;
}
// Its @mentions put right - each one an agent who can open the ticket, in an internal note only (functions/ticket_mention.php)
[$ticket_reply, $mentioned] = ticketMentionsFromHtml($ticket_reply, $row['ticket_client_id'], $ticket_reply_type === 'Internal');
$ticket_reply_html = $ticket_reply;
// Add Signature to the end of the ticket reply if not Internal and if there is reply
if ($ticket_reply !== '' && $send_email) {
$ticket_reply .= getFieldById('user_settings', $session_user_id, 'user_config_signature');
}
$ticket_reply = mysqli_real_escape_string($mysqli, $ticket_reply);
// Update Ticket Status & updated at (in case status didn't change)
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = $ticket_status, ticket_updated_at = NOW() WHERE ticket_id = $ticket_id");
syncTicketSlaClock($ticket_id);
// Resolve the ticket, if it is actually moving into Resolved - replying on an
// already-resolved ticket must not restamp resolved_at or re-log the resolve
if ($ticket_status === TICKET_STATUS_RESOLVED && $original_ticket_status !== TICKET_STATUS_RESOLVED) {
mysqli_query($mysqli, "UPDATE tickets SET ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id");
setTicketResolutionSlaMet($ticket_id);
logTicketHistory($ticket_id, "$session_name resolved the ticket");
logAudit("Ticket", "Resolve", "$session_name resolved ticket $reference", $client_id, $ticket_id);
triggerCustomAction('ticket_resolve', $ticket_id);
}
$emailable_attachments = ['send' => [], 'skipped' => []];
// Process reply actions, if we have a reply to work with (e.g. we're not just editing the status)
if ($ticket_reply !== '') {
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$ticket_reply', ticket_reply_time_worked = '$ticket_reply_time_worked', ticket_reply_type = '$ticket_reply_type', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id");
$ticket_reply_id = mysqli_insert_id($mysqli);
// Store any attached files against this reply before the email is built, so
// a public reply can carry them - the canned responses' that went into it too
$emailable_attachments = filterEmailableAttachments(array_merge(saveTicketAttachments($ticket_id, $ticket_reply_id),
cannedResponseAttachFiles((array) ($_POST['canned_response_file_ids'] ?? []), $ticket_id, $ticket_reply_id, $session_user_id)));
// The canned responses picked into it, for how often each is used
cannedResponseRecordUses((array) ($_POST['canned_response_ids'] ?? []), $ticket_id, $session_user_id);
// Tell the agents it mentions - in ITFlow and by email
$told = [];
if ($mentioned) {
ticketMentionsSync($ticket_reply_id, $mentioned);
$told = ticketMentionsTell($row, $ticket_reply_id, $ticket_reply_html, $mentioned);
if ($told) {
logTicketHistory($ticket_id, "$session_name mentioned " . escapeSql(ticketMentionNames($told)) . " in an internal note");
}
}
// Email the client and the watchers - the reply keeps who it went to, for the conversation
$emailed_to = 0;
if ($send_email) {
// The reply as saved - with the signature - read back
$emailed_to = ticketMailReply($ticket_id, (string) getFieldById('ticket_replies', $ticket_reply_id, 'ticket_reply'), stripslashes($session_name),
$ticket_status === TICKET_STATUS_RESOLVED, $emailable_attachments['send'], $ticket_reply_id);
// And a text saying so, to a contact who agreed to texts - when Texts to clients says each reply (functions/sms.php)
if ($emailed_to) {
smsTicketReplyNotify($row);
}
} else {
ticketReplyEmailed($ticket_reply_id, false);
}
// Text it to the contact's mobile, or the number texting on the ticket - as it reads, without the formatting
$texted = $send_text ? smsTicketReplySend($row, $ticket_reply_id, $ticket_reply_html, intval($session_user_id)) : null;
// Tell the agent it is assigned to, and whoever opened it - unless it mentions them, which told them already
if (!isset($told[intval($row['ticket_assigned_to'])])) {
ticketNotifyAgent($row['ticket_assigned_to'], $row, "$session_name updated Ticket " . ticketReference($row) . " - Subject: " . $row['ticket_subject'] . " that is assigned to you");
}
if (intval($row['ticket_created_by']) !== intval($row['ticket_assigned_to']) && !isset($told[intval($row['ticket_created_by'])])) {
ticketNotifyAgent($row['ticket_created_by'], $row, "$session_name updated Ticket " . ticketReference($row) . " - Subject: " . $row['ticket_subject'] . " that you opened");
}
// Handle first response
if (empty($row['ticket_first_response_at']) && $ticket_reply_type === 'Public') {
setTicketFirstResponse($ticket_id);
}
triggerCustomAction($ticket_reply_type === 'Internal' ? 'ticket_reply_agent_internal' : 'reply_reply_agent_public', $ticket_id);
$reference_html = "" . escapeHtml(ticketReference($row)) . "";
if ($ticket_reply_type === 'Internal') {
flashAlert("Internal note added to ticket $reference_html" . ($told ? " - " . escapeHtml(ticketMentionNames($told)) . " " . (count($told) === 1 ? 'was' : 'were') . " told" : ''));
} elseif ($texted) {
if ($texted['ok']) {
flashAlert("Reply added to ticket $reference_html and texted to " . escapeHtml($texted['to']) . "");
} elseif ($texted['queued']) {
flashAlert("Reply added to ticket $reference_html - the text to " . escapeHtml($texted['to']) . " is waiting to go: " . escapeHtml($texted['error']), 'warning');
} else {
flashAlert("Reply added to ticket $reference_html but not texted - " . escapeHtml($texted['error']), 'error');
}
} elseif (!$send_email) {
flashAlert("Public note added to ticket $reference_html - not emailed");
} elseif ($emailed_to) {
flashAlert("Reply added to ticket $reference_html and emailed to $emailed_to " . ($emailed_to === 1 ? 'person' : 'people'));
} else {
// They chose to email it - say plainly that it didn't go
flashAlert("Reply added to ticket $reference_html but not emailed - " . (emailReady() ? 'there\'s no contact or watcher with an email address to send it to' : 'mail isn\'t set up'), 'warning');
}
} else {
// A file uploaded without any accompanying text has no reply to hang off,
// so it attaches to the ticket itself
$emailable_attachments = filterEmailableAttachments(array_merge(saveTicketAttachments($ticket_id, null),
cannedResponseAttachFiles((array) ($_POST['canned_response_file_ids'] ?? []), $ticket_id, null, $session_user_id)));
flashAlert("Ticket updated");
}
// Tell the agent about anything too large for the mail queue to carry, rather
// than letting them assume the recipient got it
if (!empty($emailable_attachments['skipped'])) {
$skipped_names = [];
foreach ($emailable_attachments['skipped'] as $skipped_attachment) {
$skipped_names[] = escapeHtml($skipped_attachment['name']);
}
flashAlert("Stored on the ticket but too large to email: " . implode(', ', $skipped_names) . "", 'error');
}
/*
* The reply form preselects the ticket's current status, so most replies post
* it straight back - only record a status change when it actually changed.
* Resolved is left out because the resolve block above already logged it
*/
if ($ticket_status !== $original_ticket_status && $ticket_status !== TICKET_STATUS_RESOLVED) {
logTicketHistory($ticket_id, "$session_name set the status to " . escapeSql(getTicketStatusName($ticket_status)));
}
logAudit("Ticket", "Reply", "$session_name replied to ticket $reference - $subject and was a $ticket_reply_type reply" . (!empty($texted['ok']) ? ', texted' : ''), $client_id, $ticket_id);
redirect();
}
if (isset($_GET['delete_ticket_attachment'])) {
validateCSRFToken();
$attachment_id = intval($_GET['delete_ticket_attachment']);
$attachment = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_attachment_name, ticket_attachment_reference_name, ticket_attachment_ticket_id FROM ticket_attachments WHERE ticket_attachment_id = $attachment_id LIMIT 1"));
if (!$attachment) {
flashAlert('That attachment no longer exists.', 'error');
redirect();
}
$row = ticketForRequest($attachment['ticket_attachment_ticket_id'], 3);
$ticket_id = intval($row['ticket_id']);
$attachment_name = escapeSql($attachment['ticket_attachment_name']);
// Resolve the path and confirm it is still inside uploads before unlinking,
// the same guard the download endpoint applies
$uploads_base = realpath(__DIR__ . "/../../uploads");
$file_path = realpath(__DIR__ . "/../../uploads/tickets/$ticket_id/" . $attachment['ticket_attachment_reference_name']);
if ($file_path !== false && $uploads_base !== false && strpos($file_path, $uploads_base) === 0) {
unlink($file_path);
}
mysqli_query($mysqli, "DELETE FROM ticket_attachments WHERE ticket_attachment_id = $attachment_id");
logAudit("Ticket", "Delete", "$session_name deleted ticket attachment $attachment_name", $client_id, $ticket_id);
flashAlert("Attachment " . escapeHtml($attachment['ticket_attachment_name']) . " deleted", 'error');
redirect();
}
/** The reply a request names and its ticket, after the checks - [reply, ticket]. */
function ticketReplyForRequest($ticket_reply_id, $level) {
global $mysqli;
$ticket_reply_id = intval($ticket_reply_id);
$reply = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_reply_id, ticket_reply_type, ticket_reply_ticket_id FROM ticket_replies WHERE ticket_reply_id = $ticket_reply_id LIMIT 1"));
if (!$reply) {
flashAlert('That reply no longer exists.', 'error');
redirect();
}
return [$reply, ticketForRequest($reply['ticket_reply_ticket_id'], $level)];
}
if (isset($_POST['edit_ticket_reply'])) {
validateCSRFToken();
[$reply, $row] = ticketReplyForRequest($_POST['ticket_reply_id'] ?? 0, 2);
$ticket_reply_id = intval($reply['ticket_reply_id']);
// What the client wrote stays as they wrote it
if ($reply['ticket_reply_type'] === 'Client') {
flashAlert("A client's reply can't be edited - redact it instead.", 'error');
redirect();
}
// Public or internal - anything else keeps what it was
$ticket_reply_type = in_array($_POST['ticket_reply_type'] ?? '', ['Public', 'Internal'], true) ? $_POST['ticket_reply_type'] : $reply['ticket_reply_type'];
// Its @mentions put right - made public, it mentions nobody
[$ticket_reply_html, $mentioned] = ticketMentionsFromHtml((string) ($_POST['ticket_reply'] ?? ''), $row['ticket_client_id'], $ticket_reply_type === 'Internal');
$ticket_reply = mysqli_real_escape_string($mysqli, $ticket_reply_html);
$ticket_reply_time_worked = escapeSql(ticketTimeWorked($_POST['time'] ?? ''));
mysqli_query($mysqli, "UPDATE ticket_replies SET ticket_reply = '$ticket_reply', ticket_reply_type = '$ticket_reply_type', ticket_reply_time_worked = '$ticket_reply_time_worked' WHERE ticket_reply_id = $ticket_reply_id");
// Only the agents it mentions now that it didn't before are told
$told = ticketMentionsTell($row, $ticket_reply_id, $ticket_reply_html, ticketMentionsSync($ticket_reply_id, $mentioned));
if ($told) {
logTicketHistory(intval($row['ticket_id']), "$session_name mentioned " . escapeSql(ticketMentionNames($told)) . " in an internal note");
}
logAudit("Ticket", "Reply", "$session_name edited a reply on ticket " . escapeSql(ticketReference($row)), $client_id, intval($row['ticket_id']));
flashAlert("Ticket reply updated" . ($told ? " - " . escapeHtml(ticketMentionNames($told)) . " " . (count($told) === 1 ? 'was' : 'were') . " told" : ''));
redirect();
}
if (isset($_POST['redact_ticket_reply'])) {
validateCSRFToken();
[$reply, $row] = ticketReplyForRequest($_POST['ticket_reply_id'] ?? 0, 2);
$ticket_reply_id = intval($reply['ticket_reply_id']);
// A mention redacted away is taken off; redacting tells nobody
[$ticket_reply_html, $mentioned] = ticketMentionsFromHtml((string) ($_POST['ticket_reply'] ?? ''), $row['ticket_client_id'], $reply['ticket_reply_type'] === 'Internal');
$ticket_reply = mysqli_real_escape_string($mysqli, $ticket_reply_html);
mysqli_query($mysqli, "UPDATE ticket_replies SET ticket_reply = '$ticket_reply' WHERE ticket_reply_id = $ticket_reply_id");
ticketMentionsSync($ticket_reply_id, $mentioned);
logAudit("Ticket", "Reply", "$session_name redacted a reply on ticket " . escapeSql(ticketReference($row)), $client_id, intval($row['ticket_id']));
flashAlert("Ticket reply redacted");
redirect();
}
if (isset($_GET['archive_ticket_reply'])) {
validateCSRFToken();
[$reply, $row] = ticketReplyForRequest($_GET['archive_ticket_reply'], 2);
$ticket_reply_id = intval($reply['ticket_reply_id']);
mysqli_query($mysqli, "UPDATE ticket_replies SET ticket_reply_archived_at = NOW() WHERE ticket_reply_id = $ticket_reply_id");
logAudit("Ticket Reply", "Archive", "$session_name archived a reply on ticket " . escapeSql(ticketReference($row)), $client_id, $ticket_reply_id);
flashAlert("Ticket reply archived", 'error');
redirect();
}
if (isset($_POST['merge_ticket'])) {
validateCSRFToken();
// Both tickets - the one closed by the merge and the one it goes into - have to be reachable
$row = ticketForRequest($_POST['ticket_id'] ?? 0, 2);
$parent = ticketMergeTarget($_POST['merge_into_ticket_id'] ?? 0);
if (intval($row['ticket_id']) === intval($parent['ticket_id'])) {
flashAlert("A ticket can't be merged into itself.", 'error');
redirect();
}
if (!empty($row['ticket_closed_at'])) {
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " is closed already.", 'error');
redirect();
}
// Another client's ticket would show them this conversation
if (intval($row['ticket_client_id']) !== intval($parent['ticket_client_id'])) {
flashAlert("A ticket merges into one of the same client's - move it to that client first.", 'error');
redirect();
}
ticketMergeInto($row, $parent, escapeSql($_POST['merge_comment'] ?? ''), intval($_POST['merge_move_replies'] ?? 0) === 1);
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " merged into " . escapeHtml(ticketReference($parent)) . "");
redirect(ticketUrl($parent['ticket_id'], $parent['ticket_client_id']));
}
if (isset($_POST['change_client_ticket'])) {
validateCSRFToken();
// The ticket as it is - its current client has to be reachable too
$row = ticketForRequest($_POST['ticket_id'] ?? 0, 2);
$ticket_id = intval($row['ticket_id']);
$old_client_id = intval($row['ticket_client_id']);
$client_id = intval($_POST['new_client_id'] ?? 0);
if (!$client_id || !getFieldById('clients', $client_id, 'client_name')) {
flashAlert('Pick the client to move it to.', 'error');
redirect();
}
enforceClientAccess();
$contact_id = ticketClientOwnedId('contact', $_POST['new_contact_id'] ?? 0, $client_id);
// What was the old client's can't come along - our own vendor and an internal project can
$vendor_id = ticketClientOwnedId('vendor', $row['ticket_vendor_id'], 0) ?: ticketClientOwnedId('vendor', $row['ticket_vendor_id'], $client_id);
$project_id = ticketClientOwnedId('project', $row['ticket_project_id'], $client_id);
// The old client's contact saw the public replies - nobody at the new one should
mysqli_query($mysqli, "UPDATE ticket_replies SET ticket_reply_type = 'Internal' WHERE ticket_reply_ticket_id = $ticket_id AND ticket_reply_type = 'Public'");
mysqli_query($mysqli, "UPDATE tickets SET ticket_client_id = $client_id, ticket_contact_id = $contact_id, ticket_asset_id = 0, ticket_location_id = 0,
ticket_vendor_id = $vendor_id, ticket_project_id = $project_id WHERE ticket_id = $ticket_id LIMIT 1");
mysqli_query($mysqli, "DELETE FROM ticket_assets WHERE ticket_id = $ticket_id");
// The driving for it goes with it - not to the old client's site
mysqli_query($mysqli, "UPDATE trips SET trip_client_id = $client_id, trip_location_id = 0 WHERE trip_ticket_id = $ticket_id");
applyTicketSla($ticket_id);
$old_client_name = $old_client_id ? (string) getFieldById('clients', $old_client_id, 'client_name') : 'no client';
$new_client_name = (string) getFieldById('clients', $client_id, 'client_name');
logTicketHistory($ticket_id, "$session_name moved the ticket from " . escapeSql($old_client_name) . " to " . escapeSql($new_client_name));
logAudit("Ticket", "Change", "$session_name moved ticket " . escapeSql(ticketReference($row)) . " from " . escapeSql($old_client_name) . " to " . escapeSql($new_client_name), $client_id, $ticket_id);
triggerCustomAction('ticket_update', $ticket_id);
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " moved to " . escapeHtml($new_client_name) . "");
redirect(ticketUrl($ticket_id, $client_id));
}
// Take it - the list's and the board's "Assign to me"
if (isset($_GET['assign_ticket_to_me'])) {
validateCSRFToken();
$row = ticketForRequest($_GET['assign_ticket_to_me'], 2);
if (!empty($row['ticket_closed_at'])) {
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " is closed.", 'error');
redirect();
}
if (intval($row['ticket_assigned_to']) === intval($session_user_id)) {
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " is yours already.");
redirect();
}
[$ok, $message] = ticketChangeAssignee($row['ticket_id'], $session_user_id);
flashAlert($message, $ok ? 'success' : 'error');
redirect();
}
if (isset($_GET['resolve_ticket'])) {
validateCSRFToken();
$row = ticketForRequest($_GET['resolve_ticket'], 2);
$ticket_id = intval($row['ticket_id']);
if (!ticketIsOpen($row)) {
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " is resolved or closed already.", 'error');
redirect();
}
$open_tasks = ticketOpenTaskCount($ticket_id);
if ($open_tasks) {
flashAlert("Finish the $open_tasks open task" . ($open_tasks === 1 ? '' : 's') . " before resolving this ticket.", 'error');
redirect();
}
ticketResolve($row);
// Client notification email
if (intval($config_ticket_client_general_notifications) === 1) {
ticketMailResolved($ticket_id);
}
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " resolved");
redirect();
}
if (isset($_GET['close_ticket'])) {
validateCSRFToken();
$row = ticketForRequest($_GET['close_ticket'], 2);
$ticket_id = intval($row['ticket_id']);
$reference = escapeSql(ticketReference($row));
if (!empty($row['ticket_closed_at'])) {
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " is closed already.", 'error');
redirect();
}
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = " . TICKET_STATUS_CLOSED . ", ticket_resolved_at = COALESCE(ticket_resolved_at, NOW()), ticket_closed_at = NOW(), ticket_closed_by = $session_user_id WHERE ticket_id = $ticket_id");
syncTicketSlaClock($ticket_id);
setTicketResolutionSlaMet($ticket_id);
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = 'Ticket closed.', ticket_reply_type = 'Internal', ticket_reply_time_worked = '00:00:00', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id");
logTicketHistory($ticket_id, "$session_name closed the ticket");
logAudit("Ticket", "Close", "$session_name closed ticket $reference", $client_id, $ticket_id);
triggerCustomAction('ticket_close', $ticket_id);
// Client notification email
if (intval($config_ticket_client_general_notifications) === 1) {
ticketMailClosed($ticket_id);
}
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " closed - for more work on it, start a new ticket");
redirect();
}
if (isset($_GET['reopen_ticket'])) {
validateCSRFToken();
$row = ticketForRequest($_GET['reopen_ticket'], 2);
$ticket_id = intval($row['ticket_id']);
// Only a resolved ticket comes back - a closed one is final
if (!empty($row['ticket_closed_at']) || empty($row['ticket_resolved_at'])) {
flashAlert(!empty($row['ticket_closed_at']) ? "Ticket " . escapeHtml(ticketReference($row)) . " is closed - closed tickets can't be reopened. Start a new one instead." : "Ticket " . escapeHtml(ticketReference($row)) . " is open already.", 'error');
redirect();
}
ticketReopen($row);
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " reopened");
redirect();
}
/*
* Invoice a ticket - its time, at the hours and rate in the window, on a new
* draft or one of the client's drafts. billingTimeInvoice() does it, the same
* as Unbilled Time; a ticket that isn't done yet can be billed from here.
*/
if (isset($_POST['add_invoice_from_ticket'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 2);
$row = ticketForRequest($_POST['ticket_id'] ?? 0, 2);
$ticket_id = intval($row['ticket_id']);
if (!$client_id) {
flashAlert('Move the ticket to a client before invoicing it.', 'error');
redirect();
}
$result = billingTimeInvoice($client_id, [$ticket_id => (string) ($_POST['qty'] ?? '')], [
'invoice_id' => intval($_POST['invoice_id'] ?? 0),
'date' => (string) ($_POST['date'] ?? ''),
'category_id' => intval($_POST['category'] ?? 0),
'scope' => (string) ($_POST['scope'] ?? ''),
'item_name' => (string) ($_POST['item_name'] ?? ''),
'description' => isset($_POST['item_description']) ? (string) $_POST['item_description'] : null,
'price' => (string) ($_POST['price'] ?? ''),
'tax_id' => intval($_POST['tax_id'] ?? 0),
'include_open' => true,
'by' => stripslashes($session_name),
], $error);
if (!$result) {
flashAlert(escapeHtml($error), 'error');
redirect();
}
flashAlert('' . escapeHtml(ticketReference($row)) . ' billed on ' . escapeHtml($result['number']) . ' - ' . billingQuantity($result['hours']) . ' h'
. ($result['lines'] ? '' : ', with no charge'));
redirect("invoice.php?invoice_id={$result['invoice_id']}");
}
if (isset($_POST['add_quote_from_ticket'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 2);
$row = ticketForRequest($_POST['ticket_id'] ?? 0, 2);
$ticket_id = intval($row['ticket_id']);
$reference = escapeSql(ticketReference($row));
if (!$client_id) {
flashAlert('Move the ticket to a client before quoting it.', 'error');
redirect();
}
require_once 'quote_model.php';
$item_name = escapeSql($_POST['item_name'] ?? '');
$item_description = escapeSql($_POST['item_description'] ?? '');
$qty = floatval($_POST['qty'] ?? 0);
$price = floatval($_POST['price'] ?? 0);
$tax_id = intval($_POST['tax_id'] ?? 0);
// Totals - at the rate's parts, each to the cent
if ($tax_id && !taxRow($tax_id)) {
$tax_id = 0;
}
[$subtotal, $tax_amount, $total] = billingLineTax($qty, $price, $tax_id);
// Atomically increment and get the new quote number
mysqli_query($mysqli, "UPDATE settings SET config_quote_next_number = LAST_INSERT_ID(config_quote_next_number), config_quote_next_number = config_quote_next_number + 1 WHERE company_id = 1");
$quote_number = mysqli_insert_id($mysqli);
// A key for the client's guest link to it
$quote_url_key = randomString(32);
mysqli_query($mysqli, "INSERT INTO quotes SET quote_prefix = '$config_quote_prefix', quote_number = $quote_number, quote_scope = '$scope', quote_date = '$date', quote_expire = '$expire', quote_amount = $total, quote_currency_code = '$session_company_currency', quote_category_id = $category, quote_status = 'Draft', quote_url_key = '$quote_url_key', quote_client_id = $client_id");
$quote_id = mysqli_insert_id($mysqli);
mysqli_query($mysqli, "INSERT INTO quote_items SET item_name = '$item_name', item_description = '$item_description', item_quantity = $qty, item_price = $price, item_subtotal = $subtotal, item_tax = $tax_amount, item_total = $total, item_order = 1, item_tax_id = $tax_id, item_quote_id = $quote_id");
billingRecalc('quote', $quote_id);
// Add internal note to ticket, and link to quote in database
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = 'Created quote $config_quote_prefix$quote_number for this ticket.', ticket_reply_type = 'Internal', ticket_reply_time_worked = '00:00:00', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id");
mysqli_query($mysqli, "UPDATE tickets SET ticket_quote_id = $quote_id WHERE ticket_id = $ticket_id LIMIT 1");
mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Draft', history_description = 'Quote created from Ticket $reference', history_quote_id = $quote_id");
logAudit("Quote", "Create", "$session_name created quote $config_quote_prefix$quote_number from ticket $reference", $client_id, $quote_id);
triggerCustomAction('quote_create', $quote_id);
flashAlert("Quote " . escapeHtml($config_quote_prefix . $quote_number) . " created");
redirect("quote.php?quote_id=$quote_id");
}
if (isExportRequest('export_tickets')) {
validateCSRFToken();
// Exports are reads - see CONTRIBUTING.md
enforceUserPermission('module_support');
$format = resolveExportFormat($_POST['export_tickets']);
// Filters inherited from the tickets page - mirrors agent/tickets.php
$filter_summary = [];
if (!empty($_POST['client_id'])) {
$client_id = intval($_POST['client_id']);
$client_query = "AND ticket_client_id = $client_id";
$client_name = getFieldById('clients', $client_id, 'client_name');
$file_name_prepend = "$client_name-";
$filter_summary['Client'] = $client_name;
enforceClientAccess();
} else {
$client_query = '';
$client_id = 0; // for Logging
$file_name_prepend = "$session_company_name-";
// Client Filter - the global ticket list can be narrowed to one client
if (!empty($_POST['client'])) {
$filter_client_id = intval($_POST['client']);
$client_query = "AND ticket_client_id = $filter_client_id";
$filter_summary['Client'] = exportClientName($filter_client_id);
}
}
// Status Filter - a set of status IDs, or the Open / Closed shorthand
if (isset($_POST['status']) && is_array($_POST['status']) && !empty($_POST['status'])) {
$status_ids = implode(",", array_map('intval', $_POST['status']));
$ticket_status_snippet = "ticket_status IN ($status_ids)";
$status_names = [];
$sql_statuses = mysqli_query($mysqli, "SELECT ticket_status_name FROM ticket_statuses WHERE ticket_status_id IN ($status_ids) ORDER BY ticket_status_name ASC");
while ($status_row = mysqli_fetch_assoc($sql_statuses)) {
$status_names[] = $status_row['ticket_status_name'];
}
$filter_summary['Status'] = implode(', ', $status_names);
} elseif (!empty($_POST['resolution']) && $_POST['resolution'] == 'Closed') {
$ticket_status_snippet = "ticket_resolved_at IS NOT NULL";
$filter_summary['Status'] = 'Closed';
} elseif (!empty($_POST['resolution']) && $_POST['resolution'] == 'All') {
$ticket_status_snippet = "1 = 1";
$filter_summary['Status'] = 'Open and closed';
} else {
// Default - open tickets
$ticket_status_snippet = "ticket_resolved_at IS NULL";
$filter_summary['Status'] = 'Open';
}
// Billing Filter - same values the tickets page offers
$ticket_billable_snippet = '';
if (!empty($_POST['billing'])) {
if ($_POST['billing'] == 'unbilled') {
$ticket_billable_snippet = "AND ticket_billable = 1 AND ticket_invoice_id = 0";
$filter_summary['Billing'] = 'Billable, not invoiced';
} elseif ($_POST['billing'] == 'invoiced') {
$ticket_billable_snippet = "AND ticket_invoice_id > 0";
$filter_summary['Billing'] = 'Invoiced';
} elseif ($_POST['billing'] == 'nonbillable') {
$ticket_billable_snippet = "AND ticket_billable = 0";
$filter_summary['Billing'] = 'Not billable';
}
}
// Category Filter
if (!empty($_POST['category'])) {
$filter_category_id = intval($_POST['category']);
$category_query = "AND (ticket_category = $filter_category_id)";
$filter_summary['Category'] = getFieldById('categories', $filter_category_id, 'category_name');
} else {
// Default - any
$category_query = '';
}
// Assignment Filter
if (!empty($_POST['assigned'])) {
if ($_POST['assigned'] == 'unassigned') {
$ticket_assigned_query = 'AND ticket_assigned_to = 0';
$filter_summary['Assigned'] = 'Unassigned';
} else {
$filter_user_id = intval($_POST['assigned']);
$ticket_assigned_query = "AND ticket_assigned_to = $filter_user_id";
$filter_summary['Assigned'] = getFieldById('users', $filter_user_id, 'user_name');
}
} else {
// Default - any
$ticket_assigned_query = '';
}
// SLA State Filter
$ticket_sla_query = '';
if (!empty($_POST['sla'])) {
$sla_filter = $_POST['sla'];
if ($sla_filter == 'breached') {
$ticket_sla_query = 'AND ticket_sla_id > 0 AND (ticket_response_sla_alert_stage = 2 OR ticket_resolution_sla_alert_stage = 2 OR ticket_response_sla_met = 0 OR ticket_resolution_sla_met = 0)';
$filter_summary['SLA'] = 'SLA breached';
} elseif ($sla_filter == 'at_risk') {
$ticket_sla_query = 'AND ticket_sla_id > 0 AND COALESCE(ticket_status_pauses_sla, 0) = 0 AND (ticket_response_sla_alert_stage = 1 OR ticket_resolution_sla_alert_stage = 1)';
$filter_summary['SLA'] = 'SLA at risk';
} elseif ($sla_filter == 'paused') {
// Mirrors agent/tickets.php - finished tickets have a verdict, not a pause
$ticket_sla_query = 'AND ticket_sla_id > 0 AND ticket_status_pauses_sla = 1 AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL';
$filter_summary['SLA'] = 'SLA paused';
} elseif ($sla_filter == 'met') {
$ticket_sla_query = 'AND ticket_sla_id > 0 AND ticket_response_sla_met = 1 AND (ticket_resolution_sla_met = 1 OR ticket_resolution_due_at IS NULL)';
$filter_summary['SLA'] = 'SLA met';
} elseif ($sla_filter == 'none') {
$ticket_sla_query = 'AND ticket_sla_id = 0';
$filter_summary['SLA'] = 'No SLA';
}
}
// Project Filter
if (!empty($_POST['project']) && $_POST['project'] > '0') {
$filter_project_id = intval($_POST['project']);
$ticket_project_snippet = "AND ticket_project_id = $filter_project_id";
$filter_summary['Project'] = exportRecordName('projects', $filter_project_id, 'project_name', 'project_client_id');
} else {
// Default - any, including tickets without a project
$ticket_project_snippet = '';
}
// Tickets with no client stay visible to restricted agents - clientScopeSql() includes 0
$access_permission_query_overide = clientScopeSql('ticket_client_id');
// Date Filter
$dtf = escapeSql(!empty($_POST['dtf']) ? $_POST['dtf'] : '1970-01-01');
$dtt = escapeSql(!empty($_POST['dtt']) ? $_POST['dtt'] : '2099-12-31');
$date_range = formatExportDateRange($dtf, $dtt);
if ($date_range) {
$filter_summary['Opened'] = $date_range;
}
// Search Filter
$q = escapeSql($_POST['q'] ?? '');
if (!empty($q)) {
$filter_summary['Search'] = $_POST['q'];
}
// Tags - a ticket with any of them
$ticket_tag_query = tagExportFilterSql('ticket', $_POST['tags'] ?? [], $filter_summary, 'tickets.ticket_id');
// Get records from database - same shape as the tickets page list query
$sql = mysqli_query(
$mysqli,
"SELECT category_name, client_name, contact_name, ticket_billable, ticket_closed_at,
ticket_created_at, ticket_number, ticket_prefix, ticket_priority, ticket_resolved_at,
ticket_status_name, ticket_subject, user_name, " . tagNamesSql('ticket', 'tickets.ticket_id') . " AS ticket_tags 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
LEFT JOIN assets ON ticket_asset_id = asset_id
LEFT JOIN locations ON ticket_location_id = location_id
LEFT JOIN vendors ON ticket_vendor_id = vendor_id
LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id
LEFT JOIN categories ON ticket_category = category_id
WHERE $ticket_status_snippet
$ticket_assigned_query
$category_query
AND DATE(ticket_created_at) BETWEEN '$dtf' AND '$dtt'
AND (CONCAT(ticket_prefix,ticket_number) LIKE '%$q%' OR client_name LIKE '%$q%' OR ticket_subject LIKE '%$q%' OR ticket_status_name LIKE '%$q%' OR ticket_priority LIKE '%$q%' OR user_name LIKE '%$q%' OR contact_name LIKE '%$q%' OR asset_name LIKE '%$q%' OR vendor_name LIKE '%$q%'
" . tagSearchSql('ticket', $q, 'tickets.ticket_id') . ")
$ticket_tag_query
$ticket_sla_query
$ticket_billable_snippet
$ticket_project_snippet
$access_permission_query_overide
$client_query
ORDER BY ticket_number ASC"
);
$num_rows = mysqli_num_rows($sql);
guardExportHasRows($num_rows, 'tickets');
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
$export = beginExport('tickets', $format, $file_name_prepend . 'Tickets', 'Tickets', summarizeExportFilters($filter_summary));
while ($row = mysqli_fetch_assoc($sql)) {
// Per-ticket prefix where the row carries one, config default otherwise
$row['ticket_number_display'] = ($row['ticket_prefix'] ?: $config_ticket_prefix) . $row['ticket_number'];
$row['ticket_category_name'] = $row['category_name'];
$row['ticket_assigned_to'] = $row['user_name'];
$row['ticket_billable'] = $row['ticket_billable'] ? 'Yes' : 'No';
$row['ticket_tags'] = (string) $row['ticket_tags'];
addExportRow($export, $row);
}
finishExport($export);
}
logAudit("Ticket", "Export", "$session_name exported $num_rows ticket(s) to a " . strtoupper($format) . " file", $client_id);
exit;
}
if (isset($_POST['edit_ticket_schedule'])) {
validateCSRFToken();
$row = ticketForRequest($_POST['ticket_id'] ?? 0, 2);
$schedule = ticketScheduleValue($_POST['scheduled_date_time'] ?? '');
if ($schedule === '') {
flashAlert('Pick the date and time for the work.', 'error');
redirect();
}
if (!ticketIsOpen($row)) {
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " is resolved or closed - reopen it to schedule work.", 'error');
redirect();
}
$result = ticketSetSchedule($row, $schedule, intval($_POST['onsite'] ?? 0) ? 1 : 0);
flashAlert("Ticket scheduled for " . escapeHtml($result['when_text']));
if ($result['conflicts']) {
flashAlert("The same agent has other work within two hours of it:
" . implode(",
", array_map('escapeHtml', $result['conflicts'])), 'warning');
}
redirect();
}
if (isset($_GET['cancel_ticket_schedule'])) {
validateCSRFToken();
$row = ticketForRequest($_GET['cancel_ticket_schedule'], 2);
if (empty($row['ticket_schedule'])) {
flashAlert("Ticket " . escapeHtml(ticketReference($row)) . " has no work scheduled.", 'error');
redirect();
}
ticketCancelSchedule($row);
flashAlert("Ticket schedule cancelled", 'error');
redirect();
}