Support system improvement

- Made it more beautiful
- Added ability to close/reopen and escalate tickets
- Fixed some security issues
This commit is contained in:
Pinga 2024-02-16 11:42:23 +02:00
parent ee48a0e67d
commit 9b19930892
4 changed files with 188 additions and 28 deletions

View file

@ -24,7 +24,7 @@ class SupportController extends Controller
$categories = $db->select("SELECT * FROM ticket_categories"); $categories = $db->select("SELECT * FROM ticket_categories");
$category = $data['category'] ?? null; $category = $data['category'] ?? null;
$subject = $data['subject'] ?? null; $subject = htmlspecialchars($data['subject'], ENT_QUOTES, 'UTF-8') ?? null;
$message = $data['message'] ?? null; $message = $data['message'] ?? null;
if (!$subject) { if (!$subject) {
@ -103,15 +103,22 @@ class SupportController extends Controller
public function viewTicket(Request $request, Response $response, $args) public function viewTicket(Request $request, Response $response, $args)
{ {
$rawNumber = $args; $ticketNumber = filter_var($args, FILTER_VALIDATE_INT);
$ticketNumber = filter_var($rawNumber, FILTER_VALIDATE_INT); $db = $this->container->get('db');
if ($ticketNumber === false) { if ($ticketNumber === false) {
$this->container->get('flash')->addMessage('error', 'Invalid ticket number'); $this->container->get('flash')->addMessage('error', 'Invalid ticket number');
return $response->withHeader('Location', '/support')->withStatus(302); return $response->withHeader('Location', '/support')->withStatus(302);
} }
$result = $db->selectRow('SELECT registrar_id FROM registrar_users WHERE user_id = ?', [$_SESSION['auth_user_id']]);
$clid = $_SESSION["auth_roles"] != 0 ? $result['registrar_id'] : $_SESSION['auth_user_id'];
$ticket_owner = $db->selectValue('SELECT user_id FROM support_tickets WHERE id = ?', [$ticketNumber]);
if ($ticket_owner != $clid && $_SESSION["auth_roles"] != 0) {
return $response->withHeader('Location', '/support')->withStatus(302);
}
$db = $this->container->get('db');
// Get the current URI // Get the current URI
$uri = $request->getUri()->getPath(); $uri = $request->getUri()->getPath();
@ -142,7 +149,7 @@ class SupportController extends Controller
} }
public function replyTicket(Request $request, Response $response) public function replyTicket(Request $request, Response $response)
{ {
if ($request->getMethod() === 'POST') { if ($request->getMethod() === 'POST') {
// Retrieve POST data // Retrieve POST data
$data = $request->getParsedBody(); $data = $request->getParsedBody();
@ -153,6 +160,15 @@ class SupportController extends Controller
$ticket_id = $data['ticket_id'] ?? null; $ticket_id = $data['ticket_id'] ?? null;
$responseText = $data['responseText'] ?? null; $responseText = $data['responseText'] ?? null;
$result = $db->selectRow('SELECT registrar_id FROM registrar_users WHERE user_id = ?', [$_SESSION['auth_user_id']]);
$clid = $_SESSION["auth_roles"] != 0 ? $result['registrar_id'] : $_SESSION['auth_user_id'];
$ticket_owner = $db->selectValue('SELECT user_id FROM support_tickets WHERE id = ?', [$ticket_id]);
if ($ticket_owner != $clid && $_SESSION["auth_roles"] != 0) {
$this->container->get('flash')->addMessage('error', 'You do not have permission to perform this action');
return $response->withHeader('Location', '/support')->withStatus(302);
}
if (!$responseText) { if (!$responseText) {
$this->container->get('flash')->addMessage('error', 'Please enter a reply'); $this->container->get('flash')->addMessage('error', 'Please enter a reply');
@ -172,6 +188,17 @@ class SupportController extends Controller
'date_created' => $crdate, 'date_created' => $crdate,
] ]
); );
$db->update(
'support_tickets',
[
'status' => 'In Progress',
'last_updated' => $crdate
],
[
'id' => $ticket_id
]
);
$link = envi('APP_URL').'/ticket/'.$ticket_id; $link = envi('APP_URL').'/ticket/'.$ticket_id;
$email = $db->selectValue('SELECT email FROM users WHERE id = ?', [$_SESSION['auth_user_id']]); $email = $db->selectValue('SELECT email FROM users WHERE id = ?', [$_SESSION['auth_user_id']]);
@ -190,7 +217,7 @@ class SupportController extends Controller
// send message // send message
Mail::send($mailsubject, $message, $from, $to); Mail::send($mailsubject, $message, $from, $to);
$this->container->get('flash')->addMessage('success', 'Reply has been created successfully on ' . $crdate); $this->container->get('flash')->addMessage('success', 'Reply has been posted successfully on ' . $crdate);
return $response->withHeader('Location', '/ticket/'.$ticket_id)->withStatus(302); return $response->withHeader('Location', '/ticket/'.$ticket_id)->withStatus(302);
} catch (Exception $e) { } catch (Exception $e) {
$this->container->get('flash')->addMessage('error', 'Database error: '.$e->getMessage()); $this->container->get('flash')->addMessage('error', 'Database error: '.$e->getMessage());
@ -198,6 +225,88 @@ class SupportController extends Controller
} }
} }
} }
public function statusTicket(Request $request, Response $response)
{
if ($request->getMethod() === 'POST') {
// Retrieve POST data
$data = $request->getParsedBody();
$db = $this->container->get('db');
// Get the current URI
$uri = $request->getUri()->getPath();
$categories = $db->select("SELECT * FROM ticket_categories");
$ticket_id = $data['ticket_id'] ?? null;
$action = $data['action'] ?? null;
$result = $db->selectRow('SELECT registrar_id FROM registrar_users WHERE user_id = ?', [$_SESSION['auth_user_id']]);
$clid = $_SESSION["auth_roles"] != 0 ? $result['registrar_id'] : $_SESSION['auth_user_id'];
$ticket_owner = $db->selectValue('SELECT user_id FROM support_tickets WHERE id = ?', [$ticket_id]);
if ($ticket_owner != $clid && $_SESSION["auth_roles"] != 0) {
$this->container->get('flash')->addMessage('error', 'You do not have permission to perform this action');
return $response->withHeader('Location', '/support')->withStatus(302);
}
if (!$action) {
$this->container->get('flash')->addMessage('error', 'Please select an action');
return $response->withHeader('Location', '/ticket/'.$ticket_id)->withStatus(302);
}
try {
$currentDateTime = new \DateTime();
$update = $currentDateTime->format('Y-m-d H:i:s.v');
if ($action === 'close') {
$db->update(
'support_tickets',
[
'status' => 'Closed',
'last_updated' => $update
],
[
'id' => $ticket_id
]
);
$this->container->get('flash')->addMessage('success', 'Ticket has been closed successfully');
return $response->withHeader('Location', '/ticket/'.$ticket_id)->withStatus(302);
} else if ($action === 'escalate') {
$db->update(
'support_tickets',
[
'priority' => 'High',
'last_updated' => $update
],
[
'id' => $ticket_id
]
);
$this->container->get('flash')->addMessage('success', 'Ticket has been escalated successfully');
return $response->withHeader('Location', '/ticket/'.$ticket_id)->withStatus(302);
} else if ($action === 'reopen') {
$db->update(
'support_tickets',
[
'status' => 'In Progress',
'last_updated' => $update
],
[
'id' => $ticket_id
]
);
$this->container->get('flash')->addMessage('success', 'Ticket has been escalated successfully');
return $response->withHeader('Location', '/ticket/'.$ticket_id)->withStatus(302);
} else {
$this->container->get('flash')->addMessage('error', 'Incorrect action specified');
return $response->withHeader('Location', '/ticket/'.$ticket_id)->withStatus(302);
}
} catch (Exception $e) {
$this->container->get('flash')->addMessage('error', 'Database error: '.$e->getMessage());
return $response->withHeader('Location', '/ticket/'.$ticket_id)->withStatus(302);
}
}
}
public function docs(Request $request, Response $response) public function docs(Request $request, Response $response)
{ {

View file

@ -25,33 +25,65 @@
<div class="container-xl"> <div class="container-xl">
<div class="col-12"> <div class="col-12">
{% include 'partials/flash.twig' %} {% include 'partials/flash.twig' %}
<form action="/support/reply" method="post"> <form action="/support/status" method="post">
{{ csrf.field | raw }} {{ csrf.field | raw }}
<input type="hidden" name="ticket_id" value="{{ ticket.id }}"> <input type="hidden" name="ticket_id" value="{{ ticket.id }}">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="card-title">{{ __('Ticket') }} #{{ ticket.id }} - {{ ticket.subject }}</h5>&nbsp; <h3 class="card-title">{{ __('Ticket') }} #{{ ticket.id }} - {{ ticket.subject }}</h3>&nbsp;
{% if ticket.status == 'Open' %} {% if ticket.status == 'Open' %}
<span class="badge bg-success-lt">{{ ticket.status }}</span> <span class="status status-success">{{ ticket.status }}</span>
{% elseif ticket.status == 'In Progress' %} {% elseif ticket.status == 'In Progress' %}
<span class="badge bg-warning-lt">{{ ticket.status }}</span> <span class="status status-warning">{{ ticket.status }}</span>
{% elseif ticket.status == 'Resolved' %} {% elseif ticket.status == 'Resolved' %}
<span class="badge bg-info-lt">{{ ticket.status }}</span> <span class="status status-info">{{ ticket.status }}</span>
{% elseif ticket.status == 'Closed' %} {% elseif ticket.status == 'Closed' %}
<span class="badge bg-secondary-lt">{{ ticket.status }}</span> <span class="status status-cyan">{{ ticket.status }}</span>
{% else %} {% else %}
<span class="badge bg-dark-lt">{{ __('Unknown Status') }}</span> <span class="status status-teal">{{ __('Unknown Status') }}</span>
{% endif %} {% endif %}
<div class="card-actions">
{% if ticket.status != 'Closed' %}
<button type="submit" name="action" value="close" class="btn btn-ghost-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5.641 5.631a9 9 0 1 0 12.719 12.738m1.68 -2.318a9 9 0 0 0 -12.074 -12.098" /><path d="M12 17v.01" /><path d="M12 13.5a1.5 1.5 0 0 1 .394 -1.1m2.106 -1.9a2.6 2.6 0 0 0 -3.347 -3.361" /><path d="M3 3l18 18" /></svg>
Close Ticket
</button>
<button type="submit" name="action" value="escalate" class="btn btn-outline-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19.5 7h-2.672a2 2 0 0 0 -1.414 .586l-8.414 8.414h-2.5a2.5 2.5 0 1 0 0 5h3.672a2 2 0 0 0 1.414 -.586l8.414 -8.414h1.5a2.5 2.5 0 1 0 0 -5z" /><path d="M6 10v-7" /><path d="M3 6l3 -3l3 3" /></svg>
Escalate Ticket
</button>
{% else %}
<button type="submit" name="action" value="reopen" class="btn btn-outline-pink">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 12v-3a3 3 0 0 1 3 -3h13m-3 -3l3 3l-3 3" /><path d="M20 12v3a3 3 0 0 1 -3 3h-13m3 3l-3 -3l3 -3" /></svg>
Reopen Ticket
</button>
{% endif %}
</div>
</div> </div>
</form>
{% if ticket.status != 'Closed' %}<form action="/support/reply" method="post">
{{ csrf.field | raw }}
<input type="hidden" name="ticket_id" value="{{ ticket.id }}">{% endif %}
<div class="card-body"> <div class="card-body">
<h6 class="card-subtitle mb-2 text-muted">{{ __('Ticket Details') }}</h6> <h6 class="card-subtitle mb-2 text-muted">{{ __('Ticket Details') }}</h6>
<p><span>{{ __('Created On') }}:</span> <strong>{{ ticket.date_created }}</strong></p> <p><span>{{ __('Created On') }}:</span> <strong>{{ ticket.date_created }}</strong></p>
<p><span>{{ __('Category') }}:</span> <strong>{{ category }}</strong></p> <p><span>{{ __('Category') }}:</span> <span class="status status-indigo">{{ category }}</span></p>
<p><span>{{ __('Priority') }}:</span> <strong>{{ ticket.priority }}</strong></p> <p><span>{{ __('Priority') }}:</span>
{% if ticket.priority == 'Low' %}
<span class="status status-teal">{{ ticket.priority }}</span>
{% elseif ticket.priority == 'Medium' %}
<span class="status status-blue">{{ ticket.priority }}</span>
{% elseif ticket.priority == 'High' %}
<span class="status status-orange">{{ ticket.priority }}</span>
{% elseif ticket.priority == 'Critical' %}
<span class="status status-red">{{ ticket.priority }}</span>
{% else %}
<span class="status status-cyan">{{ ticket.priority }}</span>
{% endif %}</p>
<div class="card mt-3"> <div class="card mt-4">
<div class="card-body"> <div class="card-body">
<h6 class="card-subtitle mb-2 text-muted">{{ __('Conversation') }}</h6> <h6 class="card-subtitle text-muted">{{ __('Conversation') }}</h6>
{% for reply in replies %} {% for reply in replies %}
<div class="d-flex align-items-start mb-3"> <div class="d-flex align-items-start mb-3">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@ -64,7 +96,7 @@
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
<div class="d-flex align-items-start mb-3"> <div class="d-flex align-items-start">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<span class="avatar">{{ ticket.ticket_creator|slice(0, 2) }}</span> <span class="avatar">{{ ticket.ticket_creator|slice(0, 2) }}</span>
</div> </div>
@ -76,11 +108,14 @@
</div> </div>
</div> </div>
{% if ticket.status != 'Closed' %}
<div class="mt-3"> <div class="mt-3">
<label for="responseText" class="form-label">{{ __('Your Response') }}</label> <label for="responseText" class="form-label">{{ __('Your Response') }}</label>
<textarea class="form-control" id="responseText" name="responseText" rows="3" required></textarea> <textarea class="form-control" id="responseText" name="responseText" rows="3" required></textarea>
</div> </div>
{% endif %}
</div> </div>
{% if ticket.status != 'Closed' %}
<div class="card-footer"> <div class="card-footer">
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col-auto"> <div class="col-auto">
@ -88,8 +123,9 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
</div> </div>
</form> {% if ticket.status != 'Closed' %}</form>{% endif %}
</div> </div>
</div> </div>
</div> </div>

View file

@ -28,13 +28,13 @@
switch (status) { switch (status) {
case 'Open': case 'Open':
return createBadge(status, 'primary'); return createBadge(status, 'success');
case 'In Progress': case 'In Progress':
return createBadge(status, 'warning'); return createBadge(status, 'warning');
case 'Resolved': case 'Resolved':
return createBadge(status, 'success'); return createBadge(status, 'info');
case 'Closed': case 'Closed':
return createBadge(status, 'secondary'); return createBadge(status, 'cyan');
default: default:
return ""; return "";
} }
@ -45,22 +45,33 @@
// Function to create an outline badge with color based on priority // Function to create an outline badge with color based on priority
function createBadge(text, badgeClass) { function createBadge(text, badgeClass) {
return `<span class="badge bg-${badgeClass}-lt">${text}</span>`; return `<span class="status status-${badgeClass}">${text}</span>`;
} }
switch (priority) { switch (priority) {
case 'Low': case 'Low':
return createBadge(priority, 'info'); return createBadge(priority, 'teal');
case 'Medium': case 'Medium':
return createBadge(priority, 'secondary'); return createBadge(priority, 'blue');
case 'High': case 'High':
return createBadge(priority, 'warning'); return createBadge(priority, 'orange');
case 'Critical': case 'Critical':
return createBadge(priority, 'danger'); return createBadge(priority, 'red');
default: default:
return ""; return "";
} }
} }
function catFormatter(cell) {
var category = cell.getValue();
// Function to create an outline badge with color based on category
function createBadge(text, badgeClass) {
return `<span class="status status-${badgeClass}">${text}</span>`;
}
return createBadge(category, 'indigo');
}
table = new Tabulator("#supportTable", { table = new Tabulator("#supportTable", {
ajaxURL:"/api/records/support_tickets?join=ticket_categories", // Set the URL for your JSON data ajaxURL:"/api/records/support_tickets?join=ticket_categories", // Set the URL for your JSON data
@ -74,10 +85,13 @@
responsiveLayout: "collapse", responsiveLayout: "collapse",
responsiveLayoutCollapseStartOpen:false, responsiveLayoutCollapseStartOpen:false,
resizableColumns:false, resizableColumns:false,
initialSort:[
{column:"status", dir:"desc"},
],
columns:[ columns:[
{formatter:"responsiveCollapse", width:30, minWidth:30, hozAlign:"center", resizable:false, headerSort:false, responsive:0}, {formatter:"responsiveCollapse", width:30, minWidth:30, hozAlign:"center", resizable:false, headerSort:false, responsive:0},
{title:"{{ __('Subject') }}", field:"subject", width:350, minWidth:100, headerSort:true, formatter: ticketLinkFormatter, responsive:0}, {title:"{{ __('Subject') }}", field:"subject", width:350, minWidth:100, headerSort:true, formatter: ticketLinkFormatter, responsive:0},
{title:"{{ __('Category') }}", field:"category_id.name", width:250, minWidth:80, headerSort:true, responsive:0}, {title:"{{ __('Category') }}", field:"category_id.name", width:250, minWidth:80, formatter: catFormatter, headerSort:true, responsive:0},
{title:"{{ __('Status') }}", field:"status", headerSort:true, width:250, minWidth:100, formatter: statusFormatter, responsive:2}, {title:"{{ __('Status') }}", field:"status", headerSort:true, width:250, minWidth:100, formatter: statusFormatter, responsive:2},
{title:"{{ __('Priority') }}", field:"priority", headerSort:true, width:250, minWidth:100, formatter: priorityFormatter, responsive:2}, {title:"{{ __('Priority') }}", field:"priority", headerSort:true, width:250, minWidth:100, formatter: priorityFormatter, responsive:2},
{title: "{{ __('Actions') }}", formatter: actionsFormatter, headerSort: false, download:false, hozAlign: "center", responsive:0, cellClick:function(e, cell){ e.stopPropagation(); }}, {title: "{{ __('Actions') }}", formatter: actionsFormatter, headerSort: false, download:false, hozAlign: "center", responsive:0, cellClick:function(e, cell){ e.stopPropagation(); }},

View file

@ -121,6 +121,7 @@ $app->group('', function ($route) {
$route->map(['GET', 'POST'], '/support/new', SupportController::class .':newticket')->setName('newticket'); $route->map(['GET', 'POST'], '/support/new', SupportController::class .':newticket')->setName('newticket');
$route->get('/ticket/{ticket}', SupportController::class . ':viewTicket')->setName('viewTicket'); $route->get('/ticket/{ticket}', SupportController::class . ':viewTicket')->setName('viewTicket');
$route->post('/support/reply', SupportController::class . ':replyTicket')->setName('replyTicket'); $route->post('/support/reply', SupportController::class . ':replyTicket')->setName('replyTicket');
$route->post('/support/status', SupportController::class . ':statusTicket')->setName('statusTicket');
$route->get('/support/docs', SupportController::class .':docs')->setName('docs'); $route->get('/support/docs', SupportController::class .':docs')->setName('docs');
$route->get('/support/media', SupportController::class .':mediakit')->setName('mediakit'); $route->get('/support/media', SupportController::class .':mediakit')->setName('mediakit');