Compare commits

..

No commits in common. "8d0b903d34e4365755b8ea7b08fc8f7ea81c1d20" and "4b45d94ebbf1849931452efa4a19ed0f4a7da73e" have entirely different histories.

10 changed files with 279 additions and 306 deletions

View File

@ -1,16 +1,3 @@
# Coding Approach
- Classes
- Class names should be in `CapitalCamelCase`
- Class functions will be in `camelCase`
- Functions
- Function variables will be in `snake_case`
- Arrays
- Array keys will be in `snake_case`
- SQL
- table names, and columns names will be in `snake_case`
- Don't repeat yourself (DRY)
- Each fucntion should have a single purpose
# tp_servicedesk # tp_servicedesk
A { service desk, ticket, knowledge base } web application written in PHP using fat free framework. A { service desk, ticket, knowledge base } web application written in PHP using fat free framework.

View File

@ -1,58 +0,0 @@
<?php
abstract class BaseController
{
use RequiresAuth;
protected $f3;
public function __construct()
{
$this->f3 = \Base::instance();
}
// helper function
protected function getDB()
{
return $this->f3->get('DB');
}
/**
* Enforce that the user is logged in before proceeding.
*/
protected function requireLogin()
{
// using trait
$this->check_access($this->f3);
return;
// abstract
if(!$this->f3->exists('SESSION.user')){
$this->f3->set('SESSION.redirect', $this->f3->get('PATH'));
$this->f3->reroute('/login');
}
}
/**
* Set up a main layout template and inject the specified view path
* optional $data to pass variables down to template
*/
protected function renderView(string $viewPath, array $data = []):void
{
foreach($data as $key => $value){
$this->f3->set($key, $value);
}
// set {{content}}
$this->f3->set('content', $viewPath);
// render tempalte
echo \Template::instance()->render('../ui/templates/layout.html');
// clear SESSION.error
$this->f3->clear('SESSION.error');
}
}

View File

@ -1,22 +1,24 @@
<?php <?php
class TicketController extends BaseController implements CRUD { class TicketController implements CRUD {
use RequiresAuth; use RequiresAuth;
// list all tickts // list all tickts
public function index($f3){ public function index($f3){
$this->check_access($f3);
$this->requireLogin(); $db = $f3->get('DB');
// retrieve tickets // retrieve tickets
$ticket_mapper = new Ticket($this->getDB()); $tickets = $db->exec('SELECT * FROM tickets ORDER BY created_at DESC');
$tickets = $ticket_mapper->findAll();
// pass data to template
$f3->set('tickets', $tickets);
// render // render
$this->renderView('../ui/views/ticket/index.html', $f3->set('content', '../ui/views/ticket/index.html');
['tickets' => $tickets] echo \Template::instance()->render('../ui/templates/layout.html');
);
$f3->clear('SESSION.error'); $f3->clear('SESSION.error');
} }
@ -24,129 +26,296 @@ class TicketController extends BaseController implements CRUD {
// view a single ticket // view a single ticket
// TODO_PROJECTS: show a link back to the related project // TODO_PROJECTS: show a link back to the related project
public function view($f3){ public function view($f3){
$this->requireLogin(); $this->check_access($f3);
$ticket_id = $f3->get('PARAMS.id'); $ticket_id = $f3->get('PARAMS.id');
$ticket_mapper = new Ticket($this->getDB()); $db = $f3->get('DB');
$ticket = $ticket_mapper->findById($ticket_id);
$this->get_ticket($f3, $db, $ticket_id);
$this->get_child_tickets($f3, $db, $ticket_id);
$this->get_parent_tickets($f3, $db, $ticket_id);
$this->get_custom_fields($f3, $db, $ticket_id);
// render // render
$this->renderView('../ui/views/ticket/view.html', [ // $f3->set('js', 'ticket_view.js');
'ticket' => $ticket, $f3->set('content', '../ui/views/ticket/view.html');
'attachments' => $ticket->attachments(), echo \Template::instance()->render('../ui/templates/layout.html');
'comments' => $ticket->comments(),
'parent_tickets' => $ticket->getParentTickets(),
'child_tickets' => $ticket->getChildTickets(),
'ticket_meta' => $ticket->getMetaAssoc()
]);
} }
// show create form // show create form
// TODO_PROJECTS: dropdown to associate ticket with project // TODO_PROJECTS: dropdown to associate ticket with project
public function createForm($f3){ public function createForm($f3){
$this->check_access($f3);
$this->requireLogin(); $f3->set('content', '../ui/views/ticket/create.html');
$this->renderView('../ui/views/ticket/create.html'); echo \Template::instance()->render('../ui/templates/layout.html');
} }
// handle POST // handle POST
// including custom forms // including custom forms
public function create($f3){ public function create($f3){
$this->check_access($f3);
$this->requireLogin();
$data = [ $title = $f3->get('POST.title');
'title' => $this->f3->get('POST.title'), $description = $f3->get('POST.description');
'description' => $this->f3->get('POST.description'), $priority = $f3->get('POST.priority'); // eg - low, medium, high
'priority' => $this->f3->get('POST.priority'), $status = $f3->get('POST.status'); // eg - new, in_progress
'status' => $this->f3->get('POST.status'), $created_by = $f3->get('SESSION.user.id'); // current logged in user
'created_by' => $this->f3->get('SESSION.user.id')
];
$ticket_mapper = new Ticket($this->getDB()); $db = $f3->get('DB');
$new_ticket_id = $ticket_mapper->createTicket($data);
// custom field $db->exec(
$meta_keys = $this->f3->get('POST.meta_key'); 'INSERT
$meta_values = $this->f3->get('POST.meta_value'); INTO tickets (title, description, priority, status, created_by, created_at, updated_at)
$meta_assoc = $ticket_mapper->assocMetaFromKeyValue($meta_keys, $meta_values); VALUES (?,?,?,?,?,NOW(), NOW())',
$ticket_mapper->setCustomFields($meta_assoc); [$title, $description, $priority, $status, $created_by]
);
$this->f3->reroute('/ticket/' . $new_ticket_id); $ticket_id = $db->lastInsertId();
// custom fields
$meta_keys = $f3->get('POST.meta_key'); // eg ['department', 'category']
$meta_values = $f3->get('POST.meta_value');
if(is_array($meta_keys) && is_array($meta_values)){
foreach($meta_keys as $index => $key){
$val = $meta_values[$index] ?? '';
if(!empty($key) && $val !== ''){
$db->exec(
'INSERT INTO ticket_meta (ticket_id, meta_key, meta_value)
VALUES (?,?,?)',
[$ticket_id, $key, $val]
);
}
}
}
// reroute to ticket
$f3->reroute('/ticket/'.$ticket_id);
} }
// show edit form // show edit form
// including custom forms // including custom forms
// TODO_PROJECTS: allow reasssigning or removing a project association // TODO_PROJECTS: allow reasssigning or removing a project association
public function editForm($f3){ public function editForm($f3){
$this->requireLogin(); $this->check_access($f3);
$ticket_id = $f3->get('PARAMS.id'); $ticket_id = $f3->get('PARAMS.id');
$ticket_mapper = new Ticket($this->getDB()); $db = $f3->get('DB');
$ticket = $ticket_mapper->findById($ticket_id);
if(!$ticket){ $ticket = $this->get_ticket_check_edit_permission($f3);
$this->f3->set('SESSION.error', 'Ticket not found.'); $f3->set('ticket', $ticket);
$this->f3->reroute('/tickets');
}
$this->renderView('../ui/views/ticket/edit.html',[ // fetch custom fields
'ticket' => $ticket, $meta = $db->exec(
'ticket_meta' => $ticket->getMeta() 'SELECT id, meta_key, meta_value
] FROM ticket_meta
WHERE ticket_id = ?',
[$ticket_id]
); );
return; $f3->set('ticket_meta', $meta);
$f3->set('ticket', $ticket);
$f3->set('content', '../ui/views/ticket/edit.html');
echo \Template::instance()->render('../ui/templates/layout.html');
} }
// process edit POST TODO: if assigned or admin // process edit POST TODO: if assigned or admin
public function update($f3){ public function update($f3){
$this->check_access($f3);
$ticket = $this->get_ticket_check_edit_permission($f3);
$ticket_id = $ticket['id'];
$db = $f3->get('DB');
$this->requireLogin(); // get updated fields from post
$title = $f3->get('POST.title');
$description = $f3->get('POST.description');
$priority = $f3->get('POST.priority'); // eg - low, medium, high
$status = $f3->get('POST.status'); // eg - new, in_progress
$updated_by = $f3->get('SESSION.user.id'); // current logged in user
// TODO: if you want to update assignment, should be added here.
$ticket_id = $this->f3->get('PARAMS.id'); $db->exec(
$ticket_mapper = new Ticket($this->getDB()); 'UPDATE tickets
$ticket = $ticket_mapper->findById($ticket_id); SET title=?, description=?, priority=?, status=?, updated_by=?, updated_at=?
WHERE id=?',
[$title, $description, $priority, $status, $updated_by, 'NOW()', $ticket_id]
);
if(!$ticket){ // handle custom fields
$this->f3->set('SESSION.error', 'Ticket not found.'); // 1. update existing custom fields
$this->f3->reroute('/tickets'); $meta_ids = $f3->get('POST.meta_id');
$meta_keys = $f3->get('POST.meta_key');
$meta_values = $f3->get('POST.meta_value');
if(is_array($meta_ids) && is_array($meta_keys) && is_array($meta_values)){
foreach($meta_ids as $idx => $m_id){
$m_key = $meta_keys[$idx] ?? '';
$m_val = $meta_values[$idx] ?? '';
if(!empty($m_key) && $m_val !== ''){
$db->exec(
'UPDATE ticket_meta
SET meta_key = ?, meta_value=?
WHERE id = ? AND ticket_id = ?',
[$m_key, $m_val, $m_id, $ticket_id]
);
} else {
// delete if the user cleared it
$db->exec(
'DELETE FROM ticket_meta
WHERE id =? AND ticket_id = ?',
[$m_id, $ticket_id]
);
}
}
} }
$data = [ // 2. insert new fields
'title' => $this->f3->get('POST.title'), $new_keys = $f3->get('POST.new_meta_key');
'description' => $this->f3->get('POST.description'), $new_values = $f3->get('POST.new_meta_value');
'priority' => $this->f3->get('POST.priority'), if(is_array($new_keys) && is_array($new_values)){
'status' => $this->f3->get('POST.status'), foreach($new_keys as $idx => $n_key){
'updated_by' => $this->f3->get('SESSION.user.id') $n_val = $new_values[$idx] ?? '';
]; if(!empty($n_key) && $n_val !== ''){
$ticket->updateTicket($data); $db->exec(
'INSERT INTO ticket_meta (ticket_id, meta_key, meta_value)
// deal with meta data / custom fields VALUES (?,?,?)',
$meta_keys = $this->f3->get('POST.meta_key'); [$ticket_id, $n_key, $n_val]
$meta_values = $this->f3->get('POST.meta_value'); );
$meta_assoc = $ticket->assocMetaFromKeyValue($meta_keys, $meta_values); }
$ticket->setCustomFields($meta_assoc); }
}
$f3->reroute('/ticket/' . $ticket_id); $f3->reroute('/ticket/' . $ticket_id);
} }
// subtask // subtask
public function addSubtask($f3){ public function addSubtask($f3){
$this->requireLogin(); $this->check_access($f3);
$parent_id = (int) $f3->get('PARAMS.id'); $parent_id = (int) $f3->get('PARAMS.id');
$child_id = (int) $f3->get('POST.child_ticket_id'); $child_id = (int) $f3->get('POST.child_ticket_id');
$db = $f3->get('DB');
$ticket_mapper = new Ticket($this->getDB()); // TODO check that both parent and child tickets exist
$ticket = $ticket_mapper->findById($parent_id); // ensure you don't link a ticket to itself, etc.
if(!$ticket){ // insert or ignore
$this->f3->set('SESSION.error', 'Parent Ticket not found'); $db->exec(
$this->f3->reroute('/tickets'); 'INSERT IGNORE INTO ticket_relations (parent_ticket_id, child_ticket_id)
VALUES (?, ?)',
[$parent_id, $child_id]
);
$f3->reroute('/ticket/' . $parent_id);
}
protected function get_ticket_check_edit_permission($f3){
$db = $f3->get('DB');
$ticket_id = $f3->get('PARAMS.id');
$result = $db->exec('SELECT * FROM tickets WHERE id = ? LIMIT 1', [$ticket_id]);
if(!$result){
$f3->set('SESSION.error', 'Ticket not found.');
$f3->reroute('/tickets');
} }
$ticket->addChildTicket($child_id);
$this->f3->reroute('/ticket/' . $parent_id); $ticket = $result[0];
// TODO: refine
$current_user = $f3->get('SESSION.user');
$is_admin = (isset($current_user['role_name']) && $current_user['role_name'] == 'admin');
$is_assigned = ($ticket['assigned_to'] == $current_user['id']);
if(!$is_admin && !$is_assigned){ // should this be ||
// if not assigned and not admin, disallow edit
$f3->set('SESSION.error', 'You do not have permission to edit this ticket.');
$f3->reroute('/tickets');
}
return $ticket;
}
protected function get_ticket($f3, $db, $ticket_id){
// new
$db = $f3->get('DB');
$ticket_id = $f3->get('PARAMS.id');
$ticketModel = new Ticket($db);
$ticket = $ticketModel->findById($ticket_id);
if(!$ticket->dry()){
$f3->set('ticket', $ticket);
$f3->set('attachments', $ticket->attachments());
$f3->set('comments', $ticket->comments());
} else {
$f3->error(404, "Ticket not found!");
}
return;
// old:
$result = $db->exec(
'SELECT t.*, u.username as created_by_name
FROM tickets t
LEFT JOIN users u ON t.created_by = u.id
WHERE t.id =? LIMIT 1',
[$ticket_id]
);
if(!$result){
// no record
$f3->set('SESSION.error', 'Ticket not found.');
$f3->reroute('/tickets');
}
$ticket = $result[0];
$f3->set('ticket', $ticket);
}
protected function get_child_tickets($f3, $db, $ticket_id){
$child_tickets = $db->exec(
'SELECT c.*
FROM ticket_relations r
INNER JOIN tickets c ON r.child_ticket_id = c.id
WHERE r.parent_ticket_id = ?',
[$ticket_id]);
$f3->set('child_tickets', $child_tickets);
}
protected function get_parent_tickets($f3, $db, $ticket_id){
$parent_tickets = $db->exec(
'SELECT p.*
FROM ticket_relations r
INNER JOIN tickets p ON r.parent_ticket_id = p.id
WHERE r.child_ticket_id = ?',
[$ticket_id]
);
$f3->set('parent_tickets', $parent_tickets);
}
protected function get_custom_fields($f3, $db, $ticket_id){
$meta = $db->exec(
'SELECT meta_key, meta_value
FROM ticket_meta
WHERE ticket_id = ?',
[$ticket_id]
);
// convert meta rows into assoc array
$meta_array = [];
foreach($meta as $m){
$meta_array[$m['meta_key']] = $m['meta_value'];
}
$f3->set('ticket_meta', $meta_array);
} }
} }

View File

@ -5,47 +5,9 @@ class Ticket extends \DB\SQL\Mapper {
parent::__construct($db, 'tickets'); parent::__construct($db, 'tickets');
} }
/** public function findById($id){
* Return all tickets in descending order of creation
*/
public function findAll(): array
{
return $this->db->exec(
'SELECT * FROM tickets ORDER BY created_at DESC'
);
}
public function findById($id): ?Ticket
{
$this->load(['id = ?', $id]); $this->load(['id = ?', $id]);
return $this->dry() ? null : $this; return $this;
}
public function createTicket(array $data): int
{
$this->reset();
$this->title = $data['title'] ?? '';
$this->description = $data['description'] ?? '';
$this->priority = $data['priority'] ?? 'Low';
$this->status = $data['status'] ?? 'New';
$this->created_by = $data['created_by'] ?? null;
$this->created_at = date('Y-m-d H:i:s');
$this->updated_at = date('Y-m-d H:i:s');
$this->save();
return (int)$this->id;
}
public function updateTicket(array $data): void
{
if(isset($data['title'])){ $this->title = $data['title']; }
if(isset($data['description'])) { $this->descrtipion = $data['description']; }
if(isset($data['priority'])) { $this->priority = $data['priority']; }
if(isset($data['status'])) { $this->status = $data['status']; }
if(isset($data['updated_by'])) { $this->status = $data['updated_by']; }
$this->updated_at = date('Y-m-d H:i:s');
$this->save();
} }
public function attachments(){ public function attachments(){
@ -57,102 +19,4 @@ class Ticket extends \DB\SQL\Mapper {
$comment = new Comment($this->db); $comment = new Comment($this->db);
return $comment->findWithUserByTicketId($this->id); return $comment->findWithUserByTicketId($this->id);
} }
public function getParentTickets()
{
return $this->db->exec(
'SELECT p.*
FROM ticket_relations r
INNER JOIN tickets p ON r.parent_ticket_id = p.id
WHERE r.child_ticket_id = ?',
[$this->id]
);
}
public function getChildTickets()
{
return $this->db->exec(
'SELECT c.*
FROM ticket_relations r
INNER JOIN tickets c ON r.child_ticket_id = c.id
WHERE r.parent_ticket_id = ?',
[$this->id]
);
}
public function addChildTicket(int $childId)
{
$this->db->exec(
'INSERT IGNORE INTO ticket_relations (parent_ticket_id, child_ticket_id)
VALUES (?, ?)',
[$this->id, $childId]
);
}
// meta data
public function getMeta()
{
return $this->db->exec(
'SELECT id, meta_key, meta_value
FROM ticket_meta
WHERE ticket_id = ?',
[$this->id]
);
}
public function getMetaAssoc()
{
$rows = $this->getMeta();
$assoc = [];
foreach($rows as $row){
$assoc[$row['meta_key']] = $row['meta_value'];
}
return $assoc;
}
public function assocExistingMeta($meta_ids, $meta_keys, $meta_values){
if(is_array($meta_ids) && is_array($meta_keys) && is_array($meta_values)){
$field_assoc = [];
foreach($meta_ids as $i => $m_id){
$key = $meta_keys[$i] ?? '';
$value = $meta_values[$i] ?? '';
if(!empty($key) && $value !== ''){
$field_assoc[$key] = $value;
}
}
return $field_assoc;
}
return [];
}
public function assocMetaFromKeyValue($meta_keys, $meta_values)
{
if(is_array($meta_keys) && is_array($meta_values)){
$field_assoc = [];
foreach($meta_keys as $i => $key){
$val = $meta_values[$i] ?? '';
if(!empty($key) && $val != ''){
$field_assoc[$key] = $val;
}
}
return $field_assoc;
}
return [];
}
public function setCustomFields(array $fields)
{
$this->db->exec(
'DELETE FROM ticket_meta WHERE ticket_id = ?', [$this->id]
);
foreach($fields as $key => $value){
$this->db->exec(
'INSERT INTO ticket_meta (ticket_id, meta_key, meta_value)
VALUES (?, ?, ?)',
[$this->id, $key, $value]
);
}
}
} }

View File

@ -1,5 +0,0 @@
<check if="{{isset(@SESSION.error)}}">
<div class="notification is-warning">
{{ @SESSION.error }}
</div>
</check>

View File

@ -1,6 +1,12 @@
<h1 class="title">Knowledge Base</h1> <h1 class="title">Knowledge Base</h1>
<include href="/ui/session/error.html"></include>
<p><a class="button" href="/kb/create">create kb article</a></p> <check if="{{isset(@SESSION.error)}}">
<div class="notification is-warning">
{{ @SESSION.error }}
</div>
</check>
<p><a href="/kb/create">create new kb article</a></p>
<hr> <hr>
<div class="block"> <div class="block">

View File

@ -1,8 +1,6 @@
<h1 class="title">Projects</h1> <h3 class="title">Projects</h3>
<include href="/ui/session/error.html"></include> <a href="/project/create">create new project</a>
<p><a class="button" href="/project/create">create project</a></p>
<hr> <hr>
<table class="table is-fullwidth is-hoverable is-bordered"> <table class="table is-fullwidth is-hoverable is-bordered">
<thead> <thead>
<tr class="has-background-primary"> <tr class="has-background-primary">

View File

@ -1,6 +1,12 @@
<h1 class="title">Tags</h1> <h1 class="title">Tags</h1>
<include href="/ui/session/error.html"></include>
<p><a class="button" href="/tag/create">create tag</a></p> <check if="{{isset(@SESSION.error)}}">
<div class="notification is-warning">
{{ @SESSION.error }}
</div>
</check>
<p><a href="/tag/create">create new tag</a></p>
<hr> <hr>
<check if="{{ count(@tags) > 1 }}"> <check if="{{ count(@tags) > 1 }}">

View File

@ -35,12 +35,12 @@
<div class="field is-grouped is-grouped-right"> <div class="field is-grouped is-grouped-right">
<div class="control"> <div class="control">
<label class="label">Key:</label> <label class="label">Key:</label>
<input class="input" type="text" name="meta_key[]" <input class="input" type="text" name="new_meta_key[]"
placeholder="eg. Department"> placeholder="eg. Department">
</div> </div>
<div class="control"> <div class="control">
<label class="label">Value:</label> <label class="label">Value:</label>
<input class="input" type="text" name="meta_value[]" <input class="input" type="text" name="new_meta_value[]"
placeholder="eg. Finance"> placeholder="eg. Finance">
</div> </div>
</div> </div>

View File

@ -1,6 +1,12 @@
<h1 class="title">Tickets</h1> <h1 class="title">View Tickets</h1>
<include href="/ui/session/error.html">
<p><a class="button" href="/ticket/create">create ticket</a></p> <check if="{{isset(@SESSION.error)}}">
<div class="notification is-warning">
{{ @SESSION.error }}
</div>
</check>
<p><a href="/ticket/create">create ticket</a></p>
<hr> <hr>
<table class="table is-fullwidth is-bordered"> <table class="table is-fullwidth is-bordered">