mirror of
https://github.com/itflow-org/itflow
synced 2026-08-05 07:07:14 +00:00
Add ticket reply API endpoints
GET /api/v1/ticket_replies/read.php and POST create.php, so an RMM or monitoring system can append to a ticket it did not open. Replies default to Internal so an integration cannot email a client by omitting a parameter. Public replies mark first response, notify the contact and watchers, and fire the same custom actions as the agent reply handler. An optional ticket_status also sets the status, and resolves the ticket and marks the resolution SLA when set to 4. Replies are always joined to tickets so ticket_client_id is checked against the key user's client scope, and the client_id named on a write must match the ticket's own client. The reply is attributed to the user the API key runs as. read.php resolves ticket_reply_by_name, since that column holds a user_id on Internal and Public replies but a contact_id on Client ones.
This commit is contained in:
@@ -124,6 +124,7 @@ $resource_module = [
|
||||
'networks' => 'module_support',
|
||||
'software' => 'module_support',
|
||||
'tickets' => 'module_support',
|
||||
'ticket_replies' => 'module_support',
|
||||
'technicians' => 'module_support',
|
||||
'clients' => 'module_client',
|
||||
'contacts' => 'module_client',
|
||||
|
||||
187
api/v1/ticket_replies/create.php
Normal file
187
api/v1/ticket_replies/create.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
/*
|
||||
* API - Ticket Replies - Create
|
||||
* POST /api/v1/ticket_replies/create.php
|
||||
*
|
||||
* Adds a reply to an existing ticket. This is the endpoint an RMM, monitoring
|
||||
* system or chat bridge uses to append to a ticket it didn't open.
|
||||
*
|
||||
* Parameters (POST, JSON body):
|
||||
* api_key required - Your API key
|
||||
* client_id required - Must match the ticket's client (restricted
|
||||
* keys only; unrestricted/admin keys may omit)
|
||||
* ticket_id required - Ticket to reply to
|
||||
* ticket_reply required - Reply body (HTML allowed, same as the UI)
|
||||
* ticket_reply_type optional - 'Internal' (default) or 'Public'.
|
||||
* Public emails the contact and any watchers
|
||||
* and counts as the ticket's first response.
|
||||
* ticket_reply_time_worked optional - HH:MM:SS, default 00:00:00
|
||||
* ticket_status optional - Also set the ticket status. Status 4 resolves
|
||||
* the ticket (sets resolved_at + SLA met).
|
||||
*
|
||||
* Security:
|
||||
* - The parent ticket is loaded through apiClientScopeSql(), so a restricted key
|
||||
* can't reply to another client's ticket even with a valid ticket_id.
|
||||
* - The client_id supplied for the write must match the ticket's own client.
|
||||
* - The reply is attributed to the user the API key runs as ($session_user_id,
|
||||
* set by enforce_api_rbac.php), so ticket history stays honest.
|
||||
*
|
||||
* Note: 'ticket_replies' must be present in $resource_module in enforce_api_rbac.php
|
||||
* (mapped to module_support) or the enforcer will fail closed on this endpoint.
|
||||
*/
|
||||
|
||||
require_once '../validate_api_key.php';
|
||||
|
||||
require_once '../require_post_method.php';
|
||||
|
||||
// Ticket/mail settings for public reply notifications
|
||||
require_once "../../../includes/load_global_settings.php";
|
||||
|
||||
// Parse Info
|
||||
$ticket_id = intval($_POST['ticket_id'] ?? 0);
|
||||
$ticket_reply_row = false; // Creation, not an update
|
||||
require_once 'ticket_reply_model.php';
|
||||
|
||||
// Default
|
||||
$insert_id = false;
|
||||
|
||||
if (!empty($ticket_id) && !empty($reply)) {
|
||||
|
||||
// Load the parent ticket, scoped to the key user's client access
|
||||
$ticket_sql = mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT * FROM tickets
|
||||
WHERE ticket_id = $ticket_id
|
||||
AND 1=1 " . apiClientScopeSql('ticket_client_id') . "
|
||||
LIMIT 1"
|
||||
);
|
||||
$ticket_row = $ticket_sql ? mysqli_fetch_assoc($ticket_sql) : null;
|
||||
|
||||
// The client named on the write must be the ticket's own client
|
||||
if ($ticket_row && $client_id != 0 && intval($ticket_row['ticket_client_id']) !== $client_id) {
|
||||
$ticket_row = null;
|
||||
}
|
||||
|
||||
if ($ticket_row) {
|
||||
|
||||
$ticket_prefix = escapeSql($ticket_row['ticket_prefix']);
|
||||
$ticket_number = intval($ticket_row['ticket_number']);
|
||||
$ticket_subject = escapeSql($ticket_row['ticket_subject']);
|
||||
$ticket_url_key = escapeSql($ticket_row['ticket_url_key']);
|
||||
$ticket_first_response_at = escapeSql($ticket_row['ticket_first_response_at']);
|
||||
$client_id = intval($ticket_row['ticket_client_id']);
|
||||
|
||||
// Mark first response time if required - internal notes don't count as a response
|
||||
if (empty($ticket_first_response_at) && $reply_type == 'Public') {
|
||||
setTicketFirstResponse($ticket_id);
|
||||
}
|
||||
|
||||
// Add reply
|
||||
$insert_sql = mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$reply', ticket_reply_type = '$reply_type', ticket_reply_time_worked = '$reply_time_worked', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id");
|
||||
|
||||
// Check insert & get insert ID
|
||||
if ($insert_sql) {
|
||||
$insert_id = mysqli_insert_id($mysqli);
|
||||
|
||||
// Optional status change alongside the reply
|
||||
if (!empty($reply_ticket_status)) {
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = $reply_ticket_status WHERE ticket_id = $ticket_id LIMIT 1");
|
||||
|
||||
// Resolve the ticket, if set
|
||||
if ($reply_ticket_status == 4) {
|
||||
mysqli_query($mysqli, "UPDATE tickets SET ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id AND ticket_resolved_at IS NULL LIMIT 1");
|
||||
setTicketResolutionSlaMet($ticket_id);
|
||||
|
||||
logAudit("Ticket", "Resolved", "Resolved ticket $ticket_prefix$ticket_number via API ($api_key_name)", $client_id, $ticket_id);
|
||||
|
||||
triggerCustomAction('ticket_resolve', $ticket_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Logging
|
||||
logAudit("Ticket", "Reply", "Added a $reply_type reply to ticket $ticket_prefix$ticket_number - $ticket_subject via API ($api_key_name)", $client_id, $ticket_id);
|
||||
logAudit("API", "Success", "Added a $reply_type reply to ticket $ticket_prefix$ticket_number via API ($api_key_name)", $client_id);
|
||||
|
||||
// Custom action/notif handler
|
||||
if ($reply_type == 'Internal') {
|
||||
triggerCustomAction('ticket_reply_agent_internal', $ticket_id);
|
||||
} else {
|
||||
triggerCustomAction('reply_reply_agent_public', $ticket_id);
|
||||
}
|
||||
|
||||
// Email the contact & watchers on a public reply (mirrors the agent reply handler)
|
||||
if ($reply_type == 'Public' && !empty($config_smtp_provider)) {
|
||||
|
||||
$notify_sql = mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT contact_name, contact_email, ticket_status_name
|
||||
FROM tickets
|
||||
LEFT JOIN contacts ON ticket_contact_id = contact_id
|
||||
LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id
|
||||
WHERE ticket_id = $ticket_id"
|
||||
);
|
||||
$notify_row = mysqli_fetch_assoc($notify_sql);
|
||||
|
||||
$contact_name = escapeSql($notify_row['contact_name']);
|
||||
$contact_email = escapeSql($notify_row['contact_email']);
|
||||
$ticket_status_name = escapeSql($notify_row['ticket_status_name']);
|
||||
|
||||
// Sanitize config vars from load_global_settings.php
|
||||
$from_name = escapeSql($config_ticket_from_name);
|
||||
$from_email = escapeSql($config_ticket_from_email);
|
||||
|
||||
$company_sql = mysqli_query($mysqli, "SELECT company_name, company_phone, company_phone_country_code FROM companies WHERE company_id = 1");
|
||||
$company_row = mysqli_fetch_assoc($company_sql);
|
||||
$company_name = escapeSql($company_row['company_name']);
|
||||
$company_phone = escapeSql(formatPhoneNumber($company_row['company_phone'], $company_row['company_phone_country_code']));
|
||||
|
||||
$subject = "Ticket update - [$ticket_prefix$ticket_number] - $ticket_subject";
|
||||
$body = "<i style=\'color: #808080\'>##- Please type your reply above this line -##</i><br><br>Hello $contact_name,<br><br>Your ticket regarding $ticket_subject has been updated.<br><br>--------------------------------<br>$reply<br>--------------------------------<br><br>Ticket: $ticket_prefix$ticket_number<br>Subject: $ticket_subject<br>Status: $ticket_status_name<br>Portal: <a href=\'https://$config_base_url/guest/guest_view_ticket.php?ticket_id=$ticket_id&url_key=$ticket_url_key\'>View ticket</a><br><br>--<br>$company_name - Support<br>$from_email<br>$company_phone";
|
||||
|
||||
$data = [];
|
||||
|
||||
// Email ticket contact
|
||||
if (filter_var($contact_email, FILTER_VALIDATE_EMAIL)) {
|
||||
$data[] = [
|
||||
'from' => $from_email,
|
||||
'from_name' => $from_name,
|
||||
'recipient' => $contact_email,
|
||||
'recipient_name' => $contact_name,
|
||||
'subject' => $subject,
|
||||
'body' => $body
|
||||
];
|
||||
}
|
||||
|
||||
// Also email all the watchers
|
||||
$watcher_body = $body . "<br><br>----------------------------------------<br>YOU ARE A COLLABORATOR ON THIS TICKET";
|
||||
$sql_watchers = mysqli_query($mysqli, "SELECT watcher_name, watcher_email FROM ticket_watchers WHERE watcher_ticket_id = $ticket_id");
|
||||
while ($watcher_row = mysqli_fetch_assoc($sql_watchers)) {
|
||||
$watcher_name = escapeSql($watcher_row['watcher_name']);
|
||||
$watcher_email = escapeSql($watcher_row['watcher_email']);
|
||||
|
||||
if (filter_var($watcher_email, FILTER_VALIDATE_EMAIL)) {
|
||||
$data[] = [
|
||||
'from' => $from_email,
|
||||
'from_name' => $from_name,
|
||||
'recipient' => $watcher_email,
|
||||
'recipient_name' => $watcher_name,
|
||||
'subject' => $subject,
|
||||
'body' => $watcher_body
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($data)) {
|
||||
addToMailQueue($data);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Output
|
||||
require_once '../create_output.php';
|
||||
96
api/v1/ticket_replies/read.php
Normal file
96
api/v1/ticket_replies/read.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
/*
|
||||
* API - Ticket Replies - Read
|
||||
* GET /api/v1/ticket_replies/read.php
|
||||
*
|
||||
* Returns replies belonging to tickets within the key user's client access.
|
||||
*
|
||||
* Parameters (GET):
|
||||
* api_key required - Your API key
|
||||
* ticket_reply_id optional - Return a single reply by its own ID
|
||||
* ticket_id optional - Return all replies on a single ticket
|
||||
* type optional - Filter by reply type: Internal, Public or Client
|
||||
* include_archived optional - Set to 1 to include archived replies (default: excluded)
|
||||
* limit optional - Max rows to return (default 50)
|
||||
* offset optional - Offset for pagination (default 0)
|
||||
*
|
||||
* Security:
|
||||
* - ticket_replies are always INNER JOINed to tickets so that ticket_client_id is
|
||||
* checked against the key user's client scope. A restricted key can never read
|
||||
* replies on another client's ticket, even when ticket_reply_id is supplied
|
||||
* directly.
|
||||
*
|
||||
* Notes:
|
||||
* - ticket_reply_by is a user_id on Internal/Public replies and a contact_id on
|
||||
* Client replies (those come from the email parser). ticket_reply_by_name is
|
||||
* resolved here so callers don't have to know that.
|
||||
* - Archived replies are hidden everywhere in the UI, so they're excluded by
|
||||
* default here too.
|
||||
* - Unlike invoice_items/read.php this does not require a filter - an unfiltered
|
||||
* call lists all replies the key can see, matching tickets/read.php.
|
||||
*/
|
||||
|
||||
require_once '../validate_api_key.php';
|
||||
|
||||
require_once '../require_get_method.php';
|
||||
|
||||
// Archived replies are hidden throughout the UI - match that unless asked otherwise
|
||||
$archived_sql = '';
|
||||
if (empty($_GET['include_archived'])) {
|
||||
$archived_sql = " AND tr.ticket_reply_archived_at IS NULL";
|
||||
}
|
||||
|
||||
// Optional reply type filter (whitelisted, so it's safe to interpolate)
|
||||
$type_sql = '';
|
||||
if (isset($_GET['type'])) {
|
||||
$type = ucfirst(strtolower($_GET['type']));
|
||||
if (in_array($type, ['Internal', 'Public', 'Client'], true)) {
|
||||
$type_sql = " AND tr.ticket_reply_type = '$type'";
|
||||
}
|
||||
}
|
||||
|
||||
$select_sql =
|
||||
"SELECT tr.*,
|
||||
t.ticket_prefix, t.ticket_number, t.ticket_subject, t.ticket_client_id,
|
||||
COALESCE(u.user_name, c.contact_name) AS ticket_reply_by_name
|
||||
FROM ticket_replies tr
|
||||
INNER JOIN tickets t ON t.ticket_id = tr.ticket_reply_ticket_id
|
||||
LEFT JOIN users u ON tr.ticket_reply_type != 'Client' AND u.user_id = tr.ticket_reply_by
|
||||
LEFT JOIN contacts c ON tr.ticket_reply_type = 'Client' AND c.contact_id = tr.ticket_reply_by";
|
||||
|
||||
// Specific reply via ID (single)
|
||||
if (isset($_GET['ticket_reply_id'])) {
|
||||
$id = intval($_GET['ticket_reply_id']);
|
||||
$sql = mysqli_query(
|
||||
$mysqli,
|
||||
"$select_sql
|
||||
WHERE tr.ticket_reply_id = '$id'
|
||||
AND 1=1 " . apiClientScopeSql('t.ticket_client_id') . "$archived_sql$type_sql
|
||||
LIMIT 1"
|
||||
);
|
||||
|
||||
} elseif (isset($_GET['ticket_id'])) {
|
||||
// All replies on a specific ticket
|
||||
$ticket_id = intval($_GET['ticket_id']);
|
||||
$sql = mysqli_query(
|
||||
$mysqli,
|
||||
"$select_sql
|
||||
WHERE tr.ticket_reply_ticket_id = '$ticket_id'
|
||||
AND 1=1 " . apiClientScopeSql('t.ticket_client_id') . "$archived_sql$type_sql
|
||||
ORDER BY tr.ticket_reply_id ASC
|
||||
LIMIT $limit OFFSET $offset"
|
||||
);
|
||||
|
||||
} else {
|
||||
// All replies the key can see
|
||||
$sql = mysqli_query(
|
||||
$mysqli,
|
||||
"$select_sql
|
||||
WHERE 1=1 " . apiClientScopeSql('t.ticket_client_id') . "$archived_sql$type_sql
|
||||
ORDER BY tr.ticket_reply_id ASC
|
||||
LIMIT $limit OFFSET $offset"
|
||||
);
|
||||
}
|
||||
|
||||
// Output
|
||||
require_once "../read_output.php";
|
||||
45
api/v1/ticket_replies/ticket_reply_model.php
Normal file
45
api/v1/ticket_replies/ticket_reply_model.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
// Variable assignment from POST (or: blank/from DB if updating)
|
||||
|
||||
if (isset($_POST['ticket_reply'])) {
|
||||
$reply = mysqli_real_escape_string($mysqli, $_POST['ticket_reply']);
|
||||
} elseif ($ticket_reply_row) {
|
||||
$reply = mysqli_real_escape_string($mysqli, $ticket_reply_row['ticket_reply']);
|
||||
} else {
|
||||
$reply = '';
|
||||
}
|
||||
|
||||
// Reply type - defaults to Internal so an integration can't accidentally email a
|
||||
// client. 'Client' is reserved for inbound contact replies (the email parser) and
|
||||
// is not accepted here.
|
||||
if (isset($_POST['ticket_reply_type'])) {
|
||||
$reply_type = ucfirst(strtolower($_POST['ticket_reply_type']));
|
||||
if (!in_array($reply_type, ['Internal', 'Public'], true)) {
|
||||
$reply_type = 'Internal';
|
||||
}
|
||||
} elseif ($ticket_reply_row) {
|
||||
$reply_type = escapeSql($ticket_reply_row['ticket_reply_type']);
|
||||
} else {
|
||||
$reply_type = 'Internal';
|
||||
}
|
||||
|
||||
// Time worked - HH:MM:SS. Defaults to none: the API isn't a technician at a keyboard,
|
||||
// so time only gets logged when the caller explicitly says so.
|
||||
if (isset($_POST['ticket_reply_time_worked'])) {
|
||||
$reply_time_worked = escapeSql($_POST['ticket_reply_time_worked']);
|
||||
if (!preg_match('/^\d{1,3}:[0-5]\d:[0-5]\d$/', $reply_time_worked)) {
|
||||
$reply_time_worked = '00:00:00';
|
||||
}
|
||||
} elseif ($ticket_reply_row) {
|
||||
$reply_time_worked = escapeSql($ticket_reply_row['ticket_reply_time_worked']);
|
||||
} else {
|
||||
$reply_time_worked = '00:00:00';
|
||||
}
|
||||
|
||||
// Optional status change alongside the reply (0 = leave the ticket status as-is)
|
||||
if (isset($_POST['ticket_status'])) {
|
||||
$reply_ticket_status = intval($_POST['ticket_status']);
|
||||
} else {
|
||||
$reply_ticket_status = 0;
|
||||
}
|
||||
Reference in New Issue
Block a user