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:
johnnyq
2026-07-29 01:53:18 -04:00
parent 07c73a6a04
commit e76b384606
4 changed files with 329 additions and 0 deletions

View File

@@ -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',

View 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';

View 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";

View 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;
}