--- Folder Structure ---
tp_servicedesk/
LICENSE
README.md
composer.json
package.json
app/
debug.php
config/
routes.ini
controllers/
AttachmentController.php
AuthController.php
BaseController.php
CommentController.php
DashboardController.php
HomeController.php
KBController.php
ParsedownPreview.php
ProjectController.php
TagController.php
ThemeController.php
TicketController.php
UserController.php
Admin/
HomeController.php
TicketOptionsController.php
UserController.php
extensions/
BulmaFormHelper.php
CSRFHelper.php
IconsHelper.php
ParsedownHelper.php
ParsedownTableExtension.php
interfaces/
CRUD.php
models/
Attachment.php
Comment.php
Tag.php
Ticket.php
TicketPriority.php
TicketStatus.php
traits/
CheckCSRF.php
RequiresAuth.php
ui/
issues.md
modal/
partials/
ticket_item.html
parts/
clipboard.html
session/
error.html
templates/
layout.html
views/
dashboard.html
home.html
login.html
admin/
index.html
priorities/
create.html
index.html
attachment/
index.html
comments/
view.html
kb/
create.html
edit.html
index.html
view.html
project/
create.html
edit.html
index.html
view.html
tag/
create.html
index.html
ticket/
create.html
create.html.v1
edit.html
edit.html.v1
index.html
index_row.html
view.html
user/
edit.html
index.html
downloads/
lib/
public/
index.php
logo.svg
style.css
test.md.php
css/
js/
kb_edit.js
markdown_preview.js
ticket_view.js
tp_md_editor.js
tmp/
scss/
main.scss
components/
_ticket-item.scss
vendor/
_bulma-tools.scss
_bulma.scss
storage/
============================================================
--- File Contents ---
--- File: LICENSE ---
MIT License
Copyright (c) 2025 tp_dhu
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--- End File: LICENSE ---
--- File: README.md ---
# 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
A { service desk, ticket, knowledge base } web application written in PHP using fat free framework.
Used to keep track of ongoing projects/tasks, allow to view and search historic projects which may have already answered a previous question. Knowledge Base built from applications using markdown.
## Plesk - quest notes
- plesk ext composer --application -register -domain desk.tinylink.uk -path desk.tinylink.uk/tp_servicedesk
~ https://www.plesk.com/kb/support/how-to-change-in-the-php-composer-extension-the-path-of-the-composer-json-file/
## Milestones
- Database created locally
- .gitignore added
- added AuthController - login and logout process
--- End File: README.md ---
--- File: composer.json ---
{
"name": "tp/tp_servicedesk",
"description": "",
"config": {
"vendor-dir": "lib"
},
"require": {
"bcosca/fatfree-core": "^3.9",
"erusev/parsedown": "^1.7",
"ezyang/htmlpurifier": "^4.18",
"erusev/parsedown-extra": "^0.8.1",
"singular-it/parsedown-checkbox": "^0.3.5"
}
}
--- End File: composer.json ---
--- File: package.json ---
{
"dependencies": {
"bulma": "^1.0.3"
},
"scripts": {
"sass": "sass scss/main.scss public/css/main.css",
"sass:min": "sass scss/main.scss public/css/main.min.css --style compressed",
"sass:watch": "sass --watch scss/main.scss:public/css/main.css"
}
}
--- End File: package.json ---
--- File: app/debug.php ---
%s', print_r($obj, 1));
}
function debug_var_dump($obj)
{
printf('
%s
', var_dump_str($obj));
}
function var_dump_str()
{
$argc = func_num_args();
$argv = func_get_args();
if ($argc > 0) {
ob_start();
call_user_func_array('var_dump', $argv);
$result = ob_get_contents();
ob_end_clean();
return $result;
}
return '';
}
--- End File: app/debug.php ---
--- File: app/config/routes.ini ---
[routes]
; home
GET /=HomeController->display
; auth
GET /login=AuthController->showLoginForm
POST /login=AuthController->login
GET /logout=AuthController->logout
; tickets - CRUD (CREATE, READ, UPDATE, DELETE)
GET /tickets=TicketController->index
GET /ticket/@id=TicketController->view
GET /ticket/create=TicketController->createForm
POST /ticket/create=TicketController->create
GET /ticket/@id/edit=TicketController->editForm
POST /ticket/@id/update=TicketController->update
GET /ticket/@id/delete=TicketController->delete
; additional routes - comments
POST /ticket/@id/comment=CommentController->create
GET /ticket/@id/comment/@comment_id/delete=CommentController->delete
GET /ticket/@id/comments=CommentController->index
; route for linking a child to a parent
POST /ticket/@id/add-subtask=TicketController->addSubtask
; attachments
GET /ticket/@id/attachments=AttachmentController->index
POST /ticket/@id/attachments/upload=AttachmentController->upload
GET /attachment/@id/download=AttachmentController->download
GET /attachment/@id/delete=AttachmentController->delete
GET /attachment/@id/view=AttachmentController->view
; knowledgebase
GET /kb=KBController->index
GET /kb/@id=KBController->view
GET /kb/create=KBController->createForm
POST /kb/create=KBController->create
GET /kb/@id/edit=KBController->editForm
POST /kb/@id/update=KBController->update
; tags
GET /tags=TagController->index
GET /tag/create=TagController->createForm
POST /tag/create=TagController->create
; parsedown preview
POST /parsedown/preview=ParsedownPreview->view
; toggle-theme
POST /toggle-theme = ThemeController->toggle
; dashboard
GET /dashboard=DashboardController->index
; projects
GET /projects=ProjectController->index
GET /project/@id=ProjectController->view
GET /project/create=ProjectController->createForm
POST /project/create=ProjectController->create
GET /project/@id/edit=ProjectController->editForm
POST /project/@id/update=ProjectController->update
; additional routes - user
GET /users=UserController->index
GET /user/@id/edit=UserController->editForm
POST /user/@id/update=UserController->update
; admin
GET /admin=Admin\HomeController->index
; admin/priority
GET /admin/priority=Admin\TicketOptionsController->listPriorities
GET /admin/priority/create=Admin\TicketOptionsController->createPriorityForm
POST /admin/priority/create=Admin\TicketOptionsController->createPriority
GET /admin/priority/@id/edit=Admin\TicketController->editPriorityForm
POST /admin/priority/@id/update=Admin\TicketController->updatePriority
GET /admin/priority/@id/delete=Admin\TicketController->deletePriority
; admin/status
GET /admin/status=Admin\TicketOptionsController->listStatuses
GET /admin/status/create=Admin\TicketOptionsController->createStatusForm
POST /admin/status/create=Admin\TicketOptionsController->createStatus
GET /admin/status/@id/edit=Admin\TicketController->editStatusForm
POST /admin/status/@id/update=Admin\TicketController->updateStatus
GET /admin/status/@id/delete=Admin\TicketController->deleteStatus
--- End File: app/config/routes.ini ---
--- File: app/controllers/AttachmentController.php ---
check_access($f3);
$ticket_id = (int) $f3->get('PARAMS.id');
$db = $f3->get('DB');
// fetch attachments
$attachments = $db->exec(
'SELECT a.*, u.username
FROM attachments a
LEFT JOIN users u ON u.id = a.uploaded_by
WHERE a.ticket_id = ?
ORDER BY a.created_at DESC',
[$ticket_id]
);
$f3->set('ticket_id', $ticket_id);
$f3->set('attachments', $attachments);
$f3->set('content', 'views/attachment/index.html');
// echo \Template::instance()->render('templates/layout.html');
echo \Template::instance()->render($f3->get('content'));
}
// handle file upload
public function upload($f3){
$this->check_access($f3);
$this->checkCSRF($f3, '/ticket/'.$f3->get('PARAMS.id')); // not ideal for AJAX
$ticket_id = (int) $f3->get('PARAMS.id');
$uploaded_by = $f3->get('SESSION.user.id');
if(!isset($_FILES['attachment']) || $_FILES['attachment']['error'] !== UPLOAD_ERR_OK){
$f3->reroute('/ticket/'.$ticket_id.'/attachments');
}
$file_info = $_FILES['attachment'];
$original_name = $file_info['name'];
$tmp_path = $file_info['tmp_name'];
// create a unique file path
$upload_dir = '../storage/attachments/tickets/'.$ticket_id.'/';
if(!is_dir($upload_dir)){
mkdir($upload_dir, 0777, true);
}
// if file exists increment version
$db = $f3->get('DB');
$existing = $db->exec(
'SELECT * FROM attachments
WHERE ticket_id =? AND file_name = ?
ORDER BY version_number DESC
LIMIT 1',
[$ticket_id, $original_name]
);
$new_version = 1;
if($existing){
$new_version = $existing[0]['version_number'] + 1;
}
$final_path = $upload_dir.$new_version.'_'.$original_name;
// move file
move_uploaded_file($tmp_path, $final_path);
// store meta data in DB
$db->exec(
'INSERT INTO attachments
(ticket_id, path, file_name, version_number, uploaded_by, created_at)
VALUES (?,?,?,?,?,NOW())',
[$ticket_id, $final_path, $original_name, $new_version, $uploaded_by]
);
$f3->reroute('/ticket/'.$ticket_id.'');
// ideal ajax response:
// $f3->json(['success' => true, 'message' => 'file upload success.', 'filename' => $original_name, 'version' => $new_version]);
}
// download attachment
public function download($f3){
$this->check_access($f3);
$attachment_id = (int) $f3->get('PARAMS.id');
$db = $f3->get('DB');
$rows = $db->exec('SELECT * FROM attachments WHERE id = ?', [$attachment_id]);
if(!$rows){
$f3->error(404, "File not found");
return;
}
$attachment = $rows[0];
$file_path = $attachment['path'];
$file_name = $attachment['file_name'];
// validate file exists
if(!file_exists($file_path)){
$f3->error(404, "File not found");
return;
}
// output headers for download
header('Content-Description: File Transfer');
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="'.basename($file_name).'"');
header('Content-Length: '. filesize($file_path));
// flush headers
flush();
// read file
readfile($file_path);
exit;
}
// delete an attachment
public function delete($f3){
$this->check_access($f3);
$attachment_id = (int) $f3->get('PARAMS.id');
$current_user = $f3->get('SESSION.user');
$db = $f3->get('DB');
$rows = $db->exec('SELECT * FROM attachments WHERE id =? LIMIT 1', [$attachment_id]);
if(!$rows){
$f3->error(404, "Attachment not found");
return;
}
$attachment = $rows[0];
// TODO: role or ownership
if(file_exists($attachment['path'])){
unlink($attachment['path']);
}
// remove DB row
$db->exec('DELETE FROM attachments WHERE id =?', [$attachment_id]);
}
// view attachment
public function view($f3){
$this->check_access($f3);
$attachment_id = (int) $f3->get('PARAMS.id');
$db = $f3->get('DB');
$rows = $db->exec('SELECt * FROM attachments WHERE id = ?', [$attachment_id]);
if(!$rows){
$f3->error(404, "File not found");
return;
}
$attachment = $rows[0];
$file_path = $attachment['path'];
$file_name = $attachment['file_name'];
if(!file_exists($file_path)){
$f3->error(404, "File not found");
return;
}
// detect mime type
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file_path);
finfo_close($finfo);
header('Content-Type: ' . $mime_type);
header('Content-Disposition: inline; filename="' . basename($file_name) . '"');
header('Content-Length: ' . filesize($file_path));
flush();
readfile($file_path);
exit;
}
}
--- End File: app/controllers/AttachmentController.php ---
--- File: app/controllers/AuthController.php ---
set('error', $f3->get('SESSION.login_error'));
$f3->clear('SESSION.login_error');
// this can be in our controller base
$f3->set('content', 'views/login.html');
echo \Template::instance()->render('templates/layout.html');
$f3->clear('error');
}
public function login($f3){
// CSRF
$this->checkCSRF($f3, '/login');
$username = $f3->get('POST.username');
$password = $f3->get('POST.password');
$db = $f3->get('DB');
// query for user
$result = $db->exec(
'SELECT u.id, u.username, u.password, u.role, u.is_admin, r.role as role_name
FROM users u
LEFT JOIN roles r ON r.id = u.role
WHERE username =?
LIMIT 1', $username
);
// verifiy password
if($result){
$user = $result[0]; // first row
if(password_verify($password, $user['password'])){
// valid
$f3->set('SESSION.user', [
'id'=> $user['id'],
'username' => $user['username'],
'role' => $user['role'],
'role_name' => $user['role_name'],
'is_admin' => $user['is_admin']
]);
if($f3->exists('SESSION.redirect')){
$redirect = $f3->get('SESSION.redirect');
$f3->clear('SESSION.redirect');
$f3->reroute($redirect);
}
$f3->reroute('/dashboard');
} else {
$f3->set('SESSION.login_error', 'Invalid password');
}
} else {
// if here, login failed.
$f3->set('SESSION.login_error', 'Invalid username');
}
$f3->reroute('/login');
}
public function logout($f3){
$f3->clear('SESSION');
$f3->reroute('/');
}
}
--- End File: app/controllers/AuthController.php ---
--- File: app/controllers/BaseController.php ---
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');
}
}
/**
* Enforce that the user is logged in AND is an admin before proceeding.
*/
protected function requireAdmin()
{
$this->requireLogin(); // First, ensure the user is logged in
// Check if the user is an admin (assuming 'is_admin' property in session)
if (!$this->f3->get('SESSION.user.is_admin')) {
// Optionally set an error message
$this->f3->set('SESSION.error', 'Admin access required.');
$this->f3->reroute('/'); // Redirect non-admins to home page
}
}
/**
* 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('templates/layout.html');
// clear SESSION.error
$this->f3->clear('SESSION.error');
}
}
--- End File: app/controllers/BaseController.php ---
--- File: app/controllers/CommentController.php ---
exists('SESSION.user')){
$f3->reroute('/login');
}
$this->checkCSRF($f3, '/ticket/' . $f3->get('PARAMS.id'));
$ticket_id = (int) $f3->get('PARAMS.id');
$comment_text = $f3->get('POST.comment');
$current_user_id = $f3->get('SESSION.user.id');
if(empty($comment_text)){
$f3->set('SESSION.error', 'ticket not updated. No content');
$f3->reroute('/ticket/' . $ticket_id);
}
// insert comment
$db = $f3->get('DB');
$db->exec(
'INSERT INTO ticket_comments (ticket_id, comment, created_by, created_at)
VALUES (?, ?, ?, NOW())',
[$ticket_id, $comment_text, $current_user_id]
);
$f3->reroute('/ticket/' . $ticket_id);
}
/**
* Delete an existing comment
* Route: GET /tickey/@id/comment/@comment_id/delete
*/
public function delete($f3){
if(!$f3->exists('SESSION.user')){
$f3->reroute('/login');
}
$ticket_id = (int) $f3->get('PARAMS.id');
$comment_id = (int) $f3->get('PARAMS.comment_id');
$current_user = $f3->get('SESSION.user');
$db = $f3->get('DB');
//optional: check if user is allowed to delete comment.
// fetch who created the comment
$comment_row = $db->exec(
'SELECT created_by FROM ticket_comments WHERE id = ? AND ticket_id = ? LIMIT 1',
[$comment_id, $ticket_id]
);
if(!$comment_row){
$f3->set('SESSION.error', 'Error: Ticket comment ID not found.');
$f3->reroute('/ticket/'.$ticket_id);
}
$comment_owner = $comment_row[0]['created_by'];
// TODO: $is_admin = ()
if($current_user['id'] !== $comment_owner){
// no permission
$f3->set('SESSION.error', 'You do not have permission to delete this ticket');
$f3->reroute('/ticket/'. $ticket_id);
}
// Delete - addition, rather than delete, we set a delete flag
$db->exec('UPDATE ticket_comments SET deleted = 1 WHERE id = ?', [$comment_id]);
$f3->reroute('/ticket/' . $ticket_id);
}
// view comments
public function index($f3){
$ticket_id = (int) $f3->get('PARAMS.id');
$db = $f3->get('DB');
$results = $db->exec('
SELECT c.*, u.username AS author_name
FROM ticket_comments c
LEFT JOIN users u ON c.created_by = u.id
WHERE c.ticket_id = ?
ORDER BY c.created_at DESC',
[$ticket_id]
);
$comments = $results;
$f3->set('comments', $comments);
echo \Template::instance()->render('views/comments/view.html');
}
}
--- End File: app/controllers/CommentController.php ---
--- File: app/controllers/DashboardController.php ---
requireLogin();
$this->renderView('views/dashboard.html');
}
}
--- End File: app/controllers/DashboardController.php ---
--- File: app/controllers/HomeController.php ---
renderView('views/home.html');
}
// ...
}
--- End File: app/controllers/HomeController.php ---
--- File: app/controllers/KBController.php ---
check_access($f3);
$db = $f3->get('DB');
$search_term = $f3->get('GET.search');
$tag_param = $f3->get('GET.tag');
// base query
$sql = 'SELECT a.* FROM kb a';
$args = [];
if($tag_param){
$sql .= '
JOIN kb_tags AS at ON a.id = at.article_id
JOIN tags t ON at.tag_id = t.id
WHERE t.name = ?
';
$args[] = $tag_param;
if($search_term){
$sql .= ' AND LOWER(a.title) LIKE LOWER(?)';
$args[] = '%' . $search_term . '%';
}
} else if ($search_term){
$sql .= ' WHERE LOWER(a.title) LIKE LOWER(?)';
$args[] = '%' . $search_term . '%';
}
$sql .= ' ORDER BY a.created_at DESC';
$articles = $db->exec($sql, $args);
// render
$f3->set('articles', $articles);
$f3->set('content', 'views/kb/index.html');
echo \Template::instance()->render('templates/layout.html');
$f3->clear('SESSION.error');
}
/**
* Form to create new article
*/
public function createForm($f3){
$this->check_access($f3);
$db = $f3->get('DB');
$all_tags = $db->exec('SELECT * FROM tags ORDER BY name ASC');
$f3->set('all_tags', $all_tags);
// render
$f3->set('content', 'views/kb/create.html');
echo \Template::instance()->render('templates/layout.html');
$f3->clear('SESSION.error');
}
// handle POST
public function create($f3){
$this->check_access($f3);
$this->checkCSRF($f3, '/kb/create');
$title = $f3->get('POST.title');
$content = $f3->get('POST.content');
$created_by = $f3->get('SESSION.user.id');
$db = $f3->get('DB');
// insert
$db->exec(
'INSERT INTO kb (title, content, created_by, updated_by, created_at, updated_at)
VALUES (?,?,?,?, NOW(), NOW())',
[$title, $content, $created_by, $created_by]
);
$article_id = $db->lastInsertId();
// TODO: tags
$f3->reroute('/kb');
}
//
protected function check_kb_exists($article_id, $db, $f3){
$articles = $db->exec(
'SELECT * FROM kb WHERE id = ? LIMIT 1', [$article_id]
);
if(!$articles){
$f3->set('SESSION.error', 'Article not found');
$f3->reroute('/kb');
}
return $articles;
}
// view a single
public function view($f3){
$this->check_access($f3);
$article_id = $f3->get('PARAMS.id');
$db = $f3->get('DB');
$articles = $this->check_kb_exists($article_id, $db, $f3);
$article = $articles[0];
$f3->set('article', $article);
// TODO: tags
$tags = $db->exec(
'SELECT t.* FROM tags AS t
JOIN kb_tags AS at ON t.id = at.tag_id
WHERE at.kb_id = ?',
[$article_id]
);
// render
$f3->set('content', 'views/kb/view.html');
echo \Template::instance()->render('templates/layout.html');
$f3->clear('SESSION.error');
}
/**
* Form to edit existing kb article
*/
public function editForm($f3){
$this->check_access($f3);
$article_id = $f3->get('PARAMS.id');
$db = $f3->get('DB');
$articles = $this->check_kb_exists($article_id, $db, $f3);
$article = $articles[0];
$f3->set('article', $article);
// fetch current tags
$current_tag_ids = $db->exec(
'SELECT tag_id FROM kb_tags WHERE kb_id = ?', [$article_id]
);
$article_tag_ids = array_column($current_tag_ids, 'tag_id');
$f3->set('article_tag_ids', $article_tag_ids);
// render
$f3->set('js', 'kb_edit.js');
$f3->set('content', 'views/kb/edit.html');
echo \Template::instance()->render('templates/layout.html');
$f3->clear('SESSION.error');
}
/**
* Handle POST to edit existing article
*/
public function update($f3){
$this->check_access($f3);
$this->checkCSRF($f3, '/kb/' . $f3->get('PARAMS.id') . '/edit');
$article_id = $f3->get('PARAMS.id');
$db = $f3->get('DB');
$articles = $this->check_kb_exists($article_id, $db, $f3);
$article = $articles[0];
$title = $f3->get('POST.title');
$content = $f3->get('POST.content');
$updated_by = $f3->get('SESSION.user.id');
$db->exec(
'UPDATE kb
SET title=?, content=?, updated_by =?, updated_at = NOW()
WHERE id = ?',
[$title, $content, $updated_by, $article_id]
);
// update tags - first delete
$db->exec('DELETE FROM kb_tags WHERE kb_id = ?', [$article_id]);
$tags_id = $f3->get('POST.tags');
if(!empty($tags_id) && is_array($tags_id)){
foreach($tags_id as $tag_id){
$db->exec(
'INSERT IGNORE INTO kb_tags (article_id, tag_id) VALUES (?,?)',
[$article_id, $tag_id]
);
}
}
$f3->reroute('/kb/'.$article_id);
}
}
--- End File: app/controllers/KBController.php ---
--- File: app/controllers/ParsedownPreview.php ---
get('POST.content');
echo Parsedown::instance()->text($preview_text);
}
}
--- End File: app/controllers/ParsedownPreview.php ---
--- File: app/controllers/ProjectController.php ---
check_access($f3);
$db = $f3->get('DB');
// retrieve projects
$projects = $db->exec('SELECT * FROM projects ORDER BY created_at DESC');
$f3->set('projects', $projects);
$f3->set('content', 'views/project/index.html');
echo \Template::instance()->render('templates/layout.html');
$f3->clear('SESSION.error');
}
// create a new project
public function createForm($f3){
$this->check_access($f3);
$f3->set('content', 'views/project/create.html');
echo \Template::instance()->render('templates/layout.html');
}
public function create($f3){
}
// show project details including links, tickets, events, tasks
public function view($f3){
$this->check_access($f3);
$project_id = $f3->get('PARAMS.id');
$db = $f3->get('DB');
$result = $db->exec(
'SELECT * FROM projects WHERE id = ? LIMIT 1', [$project_id]
);
$project = $result[0];
$f3->set('project', $project);
$f3->set('content', 'views/project/view.html');
echo \Template::instance()->render('templates/layout.html');
}
// update project details
public function editForm($f3){
$this->check_access($f3);
$f3->set('content', 'views/project/edit.html');
echo \Template::instance()->render('templates/layout.html');
}
public function update($f3){}
}
--- End File: app/controllers/ProjectController.php ---
--- File: app/controllers/TagController.php ---
check_access($f3);
$db = $f3->get('DB');
$tags = $db->exec('SELECT * FROM tags ORDER BY name ASC');
$f3->set('tags', $tags);
$f3->set('content', 'views/tag/index.html');
echo \Template::instance()->render('templates/layout.html');
}
public function createForm($f3){
$this->check_access($f3);
$f3->set('content', 'views/tag/create.html');
echo \Template::instance()->render('templates/layout.html');
}
public function create($f3){
$this->check_access($f3);
$this->checkCSRF($f3, '/tag/create');
$name = $f3->get('POST.name');
$color = $f3->get('POST.color');
$db = $f3->get('DB');
// insert new tag
$db->exec('INSERT IGNORE INTO tags (name, color) VALUES (?, ?)', [$name, $color]);
$f3->reroute('/tags');
}
public function view($f3)
{
}
public function editForm($f3)
{
}
public function update($f3)
{
}
}
--- End File: app/controllers/TagController.php ---
--- File: app/controllers/ThemeController.php ---
checkCSRF($f3, $f3->get('HEADERS.Referer') ?: '/');
$current = $f3->get('SESSION.theme') ?: 'light';
$new_theme = ($current === 'light') ? 'dark' : 'light';
$f3->set('SESSION.theme', $new_theme);
$f3->reroute($f3->get('HEADERS.Referer') ?: '/');
}
}
--- End File: app/controllers/ThemeController.php ---
--- File: app/controllers/TicketController.php ---
requireLogin();
$filter = $f3->get('GET.status');
// retrieve tickets
$ticket_mapper = new Ticket($this->getDB());
if($filter){
$tickets = $ticket_mapper->findFiltered($filter);
} else {
$tickets = $ticket_mapper->findAll();
}
// render
$this->renderView('views/ticket/index.html',
['tickets' => $tickets]
);
$f3->clear('SESSION.error');
}
// view a single ticket
// TODO_PROJECTS: show a link back to the related project
public function view($f3){
$this->requireLogin();
$ticket_id = $f3->get('PARAMS.id');
$ticket_mapper = new Ticket($this->getDB());
$ticket = $ticket_mapper->findById($ticket_id);
if(!$ticket){
$this->f3->set('SESSION.error', 'Ticket not found');
$this->f3->reroute('/tickets');
return;
}
$assigned_user = $ticket->getAssignedUser();
$ticket_history = $ticket->getHistory();
$map_statuses = array_column((new TicketStatus($this->getDB()))->findAll(), 'name', 'id');
$map_priorities = array_column((new TicketStatus($this->getDB()))->findAll(), 'name', 'id');
$map_users = array_column($this->getDB()->exec('SELECT id, display_name FROM users'), 'display_name', 'id');
// render
$this->renderView('views/ticket/view.html', [
'ticket' => $ticket,
'assigned_user' => $assigned_user,
'attachments' => $ticket->attachments(),
'comments' => $ticket->comments(),
'parent_tickets' => $ticket->getParentTickets(),
'child_tickets' => $ticket->getChildTickets(),
'ticket_meta' => $ticket->getMetaAssoc(),
'ticket_history' => $ticket_history,
'map' => [
'statuses' => $map_statuses,
'priorities' => $map_priorities,
'users' => $map_users
]
]);
}
// show create form
// TODO_PROJECTS: dropdown to associate ticket with project
public function createForm($f3){
$db = $this->getDB();
$priorities = (new TicketPriority($db))->findAll();
$statuses = (new TicketStatus($db))->findAll();
$all_tags_model = new \Tag($this->getDB());
$all_tags = $all_tags_model->find([], ['order' => 'name ASC']); // get all tags
// TODO: this needs moving into a model?
$users = $this->getDB()->exec('SELECT id, username, display_name FROM users ORDER BY display_name ASC');
$users = array_merge([['id'=>'-1', 'display_name'=>'--']], $users);
$this->requireLogin();
$this->renderView('views/ticket/create.html',[
'priorities' => $priorities,
'statuses' => $statuses,
'users' => $users,
'all_tags' => $all_tags
]);
}
// handle POST
// including custom forms
public function create($f3){
$this->requireLogin();
$this->checkCSRF($f3, '/ticket/create');
$data = [
'title' => $this->f3->get('POST.title'),
'created_at' => $this->f3->get('POST.created_at'),
'description' => $this->f3->get('POST.description'),
'priority_id' => $this->f3->get('POST.priority_id'),
'status_id' => $this->f3->get('POST.status_id'),
'created_by' => $this->f3->get('SESSION.user.id'),
'assigned_to' => $this->f3->get('POST.assigned_to') == '-1' ? null : $this->f3->get('POST.assigned_to')
];
$ticket_mapper = new Ticket($this->getDB());
$new_ticket_id = $ticket_mapper->createTicket($data);
// custom field
// $meta_keys = $this->f3->get('POST.meta_key');
// $meta_values = $this->f3->get('POST.meta_value');
// $meta_assoc = $ticket_mapper->assocMetaFromKeyValue($meta_keys, $meta_values);
// $ticket_mapper->setCustomFields($meta_assoc);
$new_ticket = $ticket_mapper->findById($new_ticket_id);
if($new_ticket){
// TAG handling for create
$posted_tags = $this->f3->get('POST.tags');
if(!empty($posted_tags) && is_array($posted_tags)){
$new_ticket->setTags($posted_tags);
}
}
$this->f3->set('SESSION.message', 'Ticket #' . $new_ticket_id . ' created successfully.');
$this->f3->reroute('/ticket/' . $new_ticket_id);
}
// show edit form
// including custom forms
// TODO_PROJECTS: allow reasssigning or removing a project association
public function editForm($f3)
{
$this->requireLogin();
$ticket_id = $f3->get('PARAMS.id');
$ticket_mapper = new Ticket($this->getDB());
$ticket = $ticket_mapper->findById($ticket_id);
if(!$ticket){
$this->f3->set('SESSION.error', 'Ticket not found.');
$this->f3->reroute('/tickets');
}
//
$f3->set('js', 'markdown_preview.js');
// dropdowns
$priorities = (new TicketPriority($this->getDB()))->findAll();
$statuses = (new TicketStatus($this->getDB()))->findAll();
$all_tags_model = new \Tag($this->getDB());
$all_tags = $all_tags_model->find([], ['order' => 'name ASC']);
// TODO: this needs moving into a model?
$users = $this->getDB()->exec('SELECT id, username, display_name FROM users ORDER BY display_name ASC');
$users = array_merge([['id'=>'-1', 'display_name'=>'--']], $users);
// paradox - empty($ticket->tags) was returning true, when there were items present
$current_ticket_tag_ids = [];
if(count($ticket->tags) > 0){
foreach($ticket->tags as $current_tag_data){
$current_ticket_tag_ids[] = $current_tag_data['id'];
}
}
$this->renderView('views/ticket/edit.html',[
'ticket' => $ticket,
'ticket_meta' => $ticket->getMeta(),
'priorities' => $priorities,
'statuses' => $statuses,
'users' => $users,
'all_tags' => $all_tags,
'current_ticket_tag_ids' => $current_ticket_tag_ids
]
);
return;
}
// process edit POST TODO: if assigned or admin
public function update($f3)
{
$this->requireLogin();
$this->checkCSRF($f3, '/ticket/create');
$ticket_id = $f3->get('PARAMS.id');
$ticket_mapper = new Ticket($this->getDB());
$ticket = $ticket_mapper->findById($ticket_id);
if(!$ticket){
$f3->set('SESSION.error', 'Ticket not found.');
$f3->reroute('/tickets');
}
$data = [
'title' => $f3->get('POST.title'),
'created_at' => $f3->get('POST.created_at'),
'description' => $f3->get('POST.description'),
'priority_id' => $f3->get('POST.priority_id'),
'status_id' => $f3->get('POST.status_id'),
'updated_by' => $f3->get('SESSION.user.id') ,
'assigned_to' => $f3->get('POST.assigned_to') == -1 ? null : $f3->get('POST.assigned_to')
];
$ticket->updateTicket($data);
// deal with meta data / custom fields
// $meta_keys = $this->f3->get('POST.meta_key');
// $meta_values = $this->f3->get('POST.meta_value');
// $meta_assoc = $ticket->assocMetaFromKeyValue($meta_keys, $meta_values);
// $ticket->setCustomFields($meta_assoc);
$posted_tags = $f3->get('POST.tags');
if(is_array($posted_tags)){
$ticket->setTags($posted_tags);
} elseif (empty($posted_tags)){
$ticket->setTags([]);
}
$f3->set('SESSION.message', 'Ticket #' . $ticket_id . ' updated successfully.') ;
$f3->reroute('/ticket/' . $ticket_id);
}
// subtask
public function addSubtask($f3){
$this->requireLogin();
$this->checkCSRF($f3, '/ticket/create');
$parent_id = (int) $f3->get('PARAMS.id');
$child_id = (int) $f3->get('POST.child_ticket_id');
$ticket_mapper = new Ticket($this->getDB());
$ticket = $ticket_mapper->findById($parent_id);
if(!$ticket){
$this->f3->set('SESSION.error', 'Parent Ticket not found');
$this->f3->reroute('/tickets');
}
$ticket->addChildTicket($child_id);
$this->f3->reroute('/ticket/' . $parent_id);
}
public function delete(): void
{
$this->requireLogin();
$ticket_id = (int)$this->f3->get('PARAMS.id');
$ticket_mapper = new Ticket($this->getDB());
$ticket = $ticket_mapper->findById($ticket_id);
if(!$ticket){
$this->f3->set('SESSION.error', 'Ticket not found');
$this->f3->reroute('/tickets');
}
$ticket->softDelete();
$this->f3->reroute('/tickets');
}
}
--- End File: app/controllers/TicketController.php ---
--- File: app/controllers/UserController.php ---
check_access($f3);
$db = $f3->get('DB');
$users = $db->exec(
'SELECT u.*, r.role AS role_name
FROM users u
LEFT JOIN roles r ON r.id = u.role
ORDER BY id ASC'
);
$f3->set('users', $users);
$f3->set('content', 'views/user/index.html');
echo \Template::instance()->render('templates/layout.html');
}
public function editForm($f3){
$this->check_access($f3);
$user_id = (int) $f3->get('PARAMS.id');
$db = $f3->get('DB');
$rows = $db->exec(
'SELECt * FROM users WHERE id = ? LIMIT 1',
[$user_id]
);
if(!$rows){
$f3->reroute('/users');
}
$f3->set('edit_user', $rows[0]);
$f3->set('content', 'views/user/edit.html');
echo \Template::instance()->render('templates/layout.html');
}
public function update($f3){
$this->check_access($f3);
$this->checkCSRF($f3, '/user/' . $f3->get('PARAMS.id') . '/edit');
$user_id = (int) $f3->get('PARAMS.id');
$new_username = $f3->get('POST.username');
// $new_role = $f3->get('POST.role_name')
$db = $f3->get('DB');
$db->exec(
'UPDATE users SET username = ? WHERE id =? LIMIT 1',
[$new_username, $user_id]);
$f3->reroute('/users');
}
public function createForm($f3)
{
}
public function create($f3)
{
}
public function view($f3)
{
}
}
--- End File: app/controllers/UserController.php ---
--- File: app/controllers/Admin/HomeController.php ---
renderView('views/admin/index.html');
}
}
--- End File: app/controllers/Admin/HomeController.php ---
--- File: app/controllers/Admin/TicketOptionsController.php ---
requireLogin();
$this->requireAdmin(); // Added admin check
$model = new \TicketPriority($this->getDB());
$priorities = $model->findAll();
$this->renderView('views/admin/priorities/index.html', [
'priorities' => $priorities
]);
}
public function createPriorityForm()
{
$this->requireLogin();
$this->requireAdmin(); // Added admin check
$this->renderView('views/admin/priorities/create.html');
}
public function createPriority($f3)
{
$this->requireLogin();
$this->requireAdmin(); // Added admin check
$this->checkCSRF($f3, '/admin/priority/create');
$p = new \TicketPriority($this->getDB());
$p->name = $this->f3->get('POST.name');
$p->sort_order = $this->f3->get('POST.sort_order');
$p->save();
// Redirect after save
$this->f3->reroute('/admin/priorities');
}
public function editPriorityForm($f3, $params)
{
$this->requireLogin();
$this->requireAdmin();
$priorityId = $params['id'];
$model = new \TicketPriority($this->getDB());
$priority = $model->load(['id = ?', $priorityId]);
if (!$priority) {
$f3->error(404, 'Priority not found');
return;
}
$this->renderView('views/admin/priorities/edit.html', [
'priority' => $priority
]);
}
public function updatePriority($f3, $params)
{
$this->requireLogin();
$this->requireAdmin();
$this->checkCSRF($f3, '/admin/priority/', $params['id'] . '/edit');
$priorityId = $params['id'];
$model = new \TicketPriority($this->getDB());
$priority = $model->load(['id = ?', $priorityId]);
if (!$priority) {
$f3->error(404, 'Priority not found');
return;
}
$priority->name = $this->f3->get('POST.name');
$priority->sort_order = $this->f3->get('POST.sort_order');
$priority->save();
// Redirect after update
$this->f3->reroute('/admin/priorities');
}
public function deletePriority($f3, $params)
{
$this->requireLogin();
$this->requireAdmin();
$priorityId = $params['id'];
$model = new \TicketPriority($this->getDB());
$priority = $model->load(['id = ?', $priorityId]);
if (!$priority) {
// Optionally show an error message or just redirect
$this->f3->reroute('/admin/priorities');
return;
}
$priority->erase();
// Redirect after delete
$this->f3->reroute('/admin/priorities');
}
}
--- End File: app/controllers/Admin/TicketOptionsController.php ---
--- File: app/controllers/Admin/UserController.php ---
build($label);
$name = \Template::instance()->build($name);
$value = \Template::instance()->build($value);
$selected = \Template::instance()->build($selected);
if(defined("BulmaFormHelper::$type")){
$type_const = constant("BulmaFormHelper::$type");
switch( $type_const ){
case BulmaFormHelper::H_FIELD_INPUT:
return BulmaFormHelper::build_h_field_input($label, $name, $value);
break;
case BulmaFormHelper::H_FIELD_TEXTAREA:
return BulmaFormHelper::build_h_field_textarea($label, $name, $value);
break;
case BulmaFormHelper::H_FIELD_SELECT:
return BulmaFormHelper::build_h_field_select($label, $name, $options, $selected);
break;
case BulmaFormHelper::H_FIELD_SELECT_NEW:
return BulmaFormHelper::build_h_field_select_new($attr);
break;
case BulmaFormHelper::FIELD_INPUT:
return BulmaFormHelper::build_field_input($label, $name, $value, $class);
break;
case BulmaFormHelper::FIELD_TEXTAREA:
return BulmaFormHelper::build_field_textarea($label, $name, $value, $class, $rows);
break;
case BulmaFormHelper::FIELD_SELECT:
return BulmaFormHelper::build_field_select($attr);
break;
default:
return '
Error: Bulma CSS Form TYPE ('.$type.') not defined.
itself
],
];
// Replace the original element with our wrapped version:
$Block['element'] = $wrapped;
}
return $Block;
}
}
--- End File: app/extensions/ParsedownTableExtension.php ---
--- File: app/interfaces/CRUD.php ---
db->exec(
'SELECT a.*, u.username
FROM attachments a
LEFT JOIN users u ON u.id = a.uploaded_by
WHERE a.ticket_id = ?
ORDER BY a.created_at DESC',
[$ticket_id]
);
}
}
--- End File: app/models/Attachment.php ---
--- File: app/models/Comment.php ---
db->exec(
'SELECT c.*, u.username AS author_name
FROM ticket_comments c
LEFT JOIN users u ON c.created_by = u.id
WHERE c.ticket_id = ?
ORDER BY c.created_at DESC',
[$ticket_id]
);
}
}
--- End File: app/models/Comment.php ---
--- File: app/models/Tag.php ---
tag_table = $type . '_tags';
$this->tag_table_id = $type . '_id';
parent::__construct($db, $this->tag_table);
}
return $this;
}
// VERIFY: possible issue with this?
public function getTagsFor($objects, $id_key = 'id')
{
// echo $this->get('_type_id'); exit;
// printf('
%s
', print_r($this,1)); exit;
if(empty($objects)) return [];
$ids = array_column($objects, $id_key);
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$sql =
'SELECT tt.%1$s, t.id, t.name, t.color
FROM %2$s tt
INNER JOIN tags t ON tt.tag_id = t.id
WHERE tt.%1$s IN (%3$s)';
$sql_sprintf = sprintf($sql, $this->tag_table_id, $this->tag_table, $placeholders);
$rows = $this->db->exec($sql_sprintf, $ids);
$tags_map = [];
foreach($rows as $row)
{
$tags_map[$row[$this->tag_table_id]][] = $row;
}
foreach($objects as &$object)
{
$object['tags'] = $tags_map[$object[$id_key]] ?? [];
}
return $objects;
}
public function getTagsForID($id, $id_key = 'id')
{
$sql = 'SELECT tt.%1$s, t.id, t.name, t.color
FROM %2$s tt
INNER JOIN tags t ON tt.tag_id = t.id
WHERE tt.%1$s = ?';
$sql_sprintf = sprintf($sql, $this->tag_table_id, $this->tag_table);
$rows = $this->db->exec($sql_sprintf, $id);
return $rows;
}
public function findLinkedTags($id = '')
{
$sql = '
SELECT t.name, t.color
FROM `?` tt
LEFT JOIN `tags` t ON t.id = tt.id
WHERE tt.`?` = ?
';
$params = [
$this->_type,
$this->_type_id,
$id
];
return $this->db->exec($sql, $params);
}
}
--- End File: app/models/Tag.php ---
--- File: app/models/Ticket.php ---
db->exec(
'SELECT t.id, t.title, t.created_at,
tp.name AS priority_name, ts.name AS status_name, u.display_name
FROM tickets t
LEFT JOIN ticket_priorities tp ON t.priority_id = tp.id
LEFT JOIN ticket_statuses ts ON t.status_id = ts.id
LEFT JOIN users u ON t.created_by = u.id
WHERE t.recycled = 0
ORDER BY t.created_at DESC'
);
$result = $this->getTagsForTickets($tickets);
return $result;
}
public function findFiltered(string $filter): array
{
$sql = '
SELECT t.*, tp.name AS priority_name, ts.name AS status_name, u.display_name
FROM tickets t
LEFT JOIN ticket_priorities tp ON t.priority_id = tp.id
LEFT JOIN ticket_statuses ts ON t.status_id = ts.id
LEFT JOIN users u ON t.created_by = u.id
WHERE t.recycled = 0
';
$params = [];
switch($filter){
case 'open':
$sql .= ' AND status_id = ?';
$params[] = 1;
break;
case 'in_progress':
$sql .= ' AND status_id = ?';
$params[] = 2;
break;
case 'on_hold':
$sql .= ' AND status_id = ?';
$params[] = 3;
break;
case 'completed':
$sql .= ' AND status_id = ?';
$params[] = 4;
break;
}
$sql .= ' ORDER BY t.created_at DESC';
$tickets = $this->db->exec($sql, $params);
$result = $this->getTagsForTickets($tickets);
return $result;
}
public function getTagsForTickets(array $tickets)
{
if(empty($tickets)) return [];
$tag_mapper = new Tag($this->db, 'ticket');
$tickets = $tag_mapper->getTagsFor($tickets);
return $tickets;
}
public function findById($id): ?Ticket
{
$this->status_name = 'SELECT name FROM ticket_statuses WHERE tickets.status_id = ticket_statuses.id';
$this->priority_name = 'SELECT name FROM ticket_priorities WHERE tickets.priority_id = ticket_priorities.id';
$this->load(['id = ?', $id]);
if($this->dry()){
return null;
}
$tag_model = new Tag($this->db, 'ticket');
$this->tags = $tag_model->getTagsForID($this->id, 'ticket_id');
return $this;
}
public function setTags(array $tags_ids):void {
if($this->dry() || !$this->id){
// can't set tags for a ticket that hasn't been saved or loaded
return;
}
// remove existing tags - TODO: shouldn't this be in the tag model?
$this->db->exec('DELETE FROM ticket_tags WHERE ticket_id = ?', [$this->id]);
if(!empty($tags_ids)){
$sql_insert_tag = 'INSERT INTO ticket_tags (ticket_id, tag_id) VALUES (?,?)';
foreach($tags_ids as $tag_id){
if(filter_var($tag_id, FILTER_VALIDATE_INT)){
$this->db->exec($sql_insert_tag, [$this->id, (int)$tag_id]);
}
}
}
// refresh tags
$tag_model = new Tag($this->db, 'ticket');
$this->tags = $tag_model->getTagsForID($this->id, 'ticket_id');
}
public function createTicket(array $data): int
{
$this->reset();
$this->title = $data['title'] ?? '';
$this->description = $data['description'] ?? '';
//
$this->priority_id = $data['priority_id'] ?? null;
$this->status_id = $data['status_id'] ?? null;
//
$this->created_by = $data['created_by'] ?? null;
$this->created_at = ($data['created_at'] == '' ? date('Y-m-d H:i:s') : $data['created_at']) ?? date('Y-m-d H:i:s');
$this->updated_at = date('Y-m-d H:i:s');
$this->save();
$this->logCreate();
return (int)$this->id;
}
public function updateTicket(array $data): void
{
$this->logDiff($data);
if(isset($data['title'])){ $this->title = $data['title']; }
if(isset($data['description'])) { $this->description = $data['description']; }
if(isset($data['priority_id'])) { $this->priority_id = $data['priority_id']; }
if(isset($data['status_id'])) { $this->status_id = $data['status_id']; }
if(isset($data['updated_by'])) { $this->updated_by = $data['updated_by']; }
if(isset($data['assigned_to'])) {
if($data['assigned_to'] == '-1'){
$this->assigned_to = null;
} else {
$this->assigned_to = $data['assigned_to'];
}
}
$this->created_at = ($data['created_at'] == '' ? date('Y-m-d H:i:s') : $data['created_at']) ?? date('Y-m-d H:i:s');
$this->updated_at = date('Y-m-d H:i:s');
$this->save();
}
public function softDelete():void {
$this->recycled = 1;
$this->save();
}
public function attachments(){
$attachment = new Attachment($this->db);
return $attachment->findWithUserByTicketId($this->id);
}
public function comments(){
$comment = new Comment($this->db);
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]
);
}
}
public function getAssignedUser()
{
if(!$this->assigned_to){
return null;
}
$sql = '
SELECT id, username, display_name
FROM users
WHERE id =?
';
$user = $this->db->exec($sql, [$this->assigned_to]);
return $user[0];
}
/**
* Logs a change to the ticket history.
* @param int $user_id - the ID of the user making the change.
* @param string $field_changed - the name of the field that was changed.
* @param string|null $old_value - the old value
* @param string|null $new_value - the new value
*/
public function logHistory(int $user_id, string $field_changed,
$old_value = null, $new_value = null,
$changed_at = null): void
{
if($this->dry() || !$this->id) return;
$history = new \DB\SQL\Mapper($this->db, 'ticket_history');
$history->ticket_id = $this->id;
$history->user_id = $user_id;
$history->field_changed = $field_changed;
$history->old_value = $old_value === null ? null : (string)$old_value;
$history->new_value = $new_value === null ? null : (string)$new_value;
if($changed_at == null){
$history->changed_at = date('Y-m-d H:i:s');
} else {
$history->changed_at = $changed_at;
}
$history->save();
}
public function getHistory(): array {
if($this->dry() || !$this->id) return[];
$sql = 'SELECT th.*, u.display_name as user_display_name
FROM ticket_history th
JOIN users u ON th.user_id = u.id
WHERE th.ticket_id = ?
ORDER BY th.changed_at DESC';
return $this->db->exec($sql, [$this->id]);
}
/**
* called from create
*/
public function logCreate(){
$changed_at = date('Y-m-d H:i:s');
$user_making_change = \Base::instance()->get('SESSION.user.id');
$this->logHistory($user_making_change, 'ticket_created', null, $this->title, $changed_at);
if($this->status_id) $this->logHistory($user_making_change, 'status_id', null, $this->status_id, $changed_at);
if($this->priority_id) $this->logHistory($user_making_change, 'priority_id', null, $this->priority_id, $changed_at);
if($this->assigned_to) $this->logHistory($user_making_change, 'assigned_to', null, $this->assigned_to, $changed_at);
}
/**
* called from update
*/
public function logDiff($data){
$user_making_change = \Base::instance()->get('SESSION.user.id');
$changed_at = date('Y-m-d H:i:s');
$checks = ['title', 'description', 'priority_id', 'status_id', 'assigned_to', 'updated_by'];
// loop instead
foreach($checks as $check){
if(isset($data[$check]) && $this->$check != $data[$check]){
if($check == 'description'){
$old_hash = hash('sha256', $this->$check);
$new_hash = hash('sha256', $data[$check]);
$this->logHistory($user_making_change, $check, $old_hash, $new_hash, $changed_at);
} else {
$this->logHistory($user_making_change, $check, $this->$check, $data[$check], $changed_at);
}
}
}
}
}
--- End File: app/models/Ticket.php ---
--- File: app/models/TicketPriority.php ---
db->exec(
'SELECT * FROM ticket_priorities ORDER BY sort_order ASC'
);
}
}
--- End File: app/models/TicketPriority.php ---
--- File: app/models/TicketStatus.php ---
db->exec(
'SELECT * FROM ticket_statuses ORDER BY sort_order ASC'
);
}
}
--- End File: app/models/TicketStatus.php ---
--- File: app/traits/CheckCSRF.php ---
get('POST.' . \CSRFHelper::TOKEN_NAME))){
$f3->set('SESSION.error', 'CSRF token validation failed.');
$f3->reroute($reroute);
return;
}
}
}
--- End File: app/traits/CheckCSRF.php ---
--- File: app/traits/RequiresAuth.php ---
exists('SESSION.user')){
// $f3->set('SESSION.error', 'You don\'t have permission for this ticket.');
$f3->set('SESSION.redirect', $f3->get('PATH'));
$f3->reroute('/login');
}
}
}
--- End File: app/traits/RequiresAuth.php ---
--- File: app/ui/issues.md ---
# MVP Issues
This issue list defines the work required to bring the `tp_servicedesk` Fat-Free PHP application to a beta-ready MVP for personal workload/project tracking.
---
## 🧩 Ticketing System
- [x] Display assigned user in ticket view
- [x] Add user assignment capability in ticket create/edit forms
- [ ] Implement ticket filtering (by status, assignee, project)
- [ ] Improve UI feedback for ticket soft-delete
- [ ] Ensure metadata, attachments, and comments update properly on edit
- [ ] Add tag support for tickets (UI and DB updates)
- [ ] Implement ticket history for status/priority changes
- [ ] Add comment thread display on ticket edit view
- [x] Update "new ticket" template to match the edit form
- [ ] Enable linking tickets using markdown shortcodes (e.g. `#ticket-id` autocomplete)
---
## 📂 Project Management
- [ ] Implement `ProjectController::create()` and `update()` logic
- [ ] Add CRUD for:
- [ ] `project_tasks` (task list under project)
- [ ] `project_links` (related links/resources)
- [ ] `project_events` (project timeline/log)
- [ ] Show tickets related to a project in the project view page
---
## 📚 Knowledge Base (KB)
- [ ] Display and edit tags for each article
- [ ] Show article creator and last updated info
- [ ] Add search suggestions or recent articles on main KB page
---
## 👤 User Management
- [ ] Implement full CRUD in `Admin\UserController`
- [ ] Add role selection to user creation/edit forms
- [ ] Restrict sensitive actions (ticket edit/delete) to owner or admin
---
## ⚙️ Admin Panel
- [ ] Complete admin routes and views for ticket status management
- [ ] Add dashboard widgets (open tickets count, recent updates, etc.)
- [ ] Create settings/config view to manage global app options
---
## 📦 UI & UX Polish
- [ ] Show success/error alerts for all user actions
- [ ] Apply consistent Bulma styling to forms, buttons, and messages
- [ ] Add loading indicators or skeletons on longer actions
- [ ] Embed `/public/test.md.php` JS markdown editor into edit/create ticket templates
- [ ] Add smart shortcuts to markdown editor:
- [ ] `#` opens ticket linking modal
- [ ] `@` opens user tagging modal
- [ ] `!a` opens hyperlink creation modal
---
## 🧪 Quality Assurance
- [ ] Create fake/test data seeder
- [ ] Add DB integrity checks for tickets, users, and project FK links
- [ ] Manually test:
- [x] Login/logout
- [x] Ticket create/edit/delete
- [ ] KB CRUD
- [ ] Attachment upload/download
- [ ] Audit all routes and controllers for authentication and access checks
---
## 🔍 Search & Indexing
- [ ] Improve full-text search to be smarter than `LIKE %{query}%`
- [ ] Add partial, fuzzy, and prioritised relevance to results (e.g. `title > tags > content`)
---
## 📅 Timeline & Activity Views
- [ ] Create timeline view per ticket using:
- [ ] status/priority history
- [ ] comments
- [ ] attachments and other actions
- [ ] Build global timeline dashboard to see activity across system
- [ ] Implement calendar heatmap (e.g. GitHub-style) for activity tracking
- [ ] Add drilldown support from calendar to view specific actions on each day
- [ ] Add detailed log/summary of:
- [ ] ticket creation/updates
- [ ] project creation/updates
- [ ] assignments
- [ ] comments
- [ ] closures
---
## 🔐 Security & Session
- [x] Add CSRF protection for all POST forms
- [ ] Enforce permission checks on:
- [ ] Ticket edits
- [ ] Comment deletion
- [ ] Attachment deletion
- [ ] Add password reset flow or admin-reset function
---
## 📈 Post-MVP (Optional Enhancements)
- [ ] Ticket due dates and reminders
- [ ] Kanban or Gantt-style project view
- [ ] REST API endpoints
---
--- End File: app/ui/issues.md ---
--- File: app/ui/partials/ticket_item.html ---
---
## View project
A central place to see everything for this project:
- Overview: (title, description, links, start/end dates).
- related tickets (with status and priorities)
- events
- tasks
- timeline combining events, tickets, milestone dates
## Example Workflow
- create a project - `team manager overview`
-- attach relevant links
- add tickets - each new request or issue can be a ticket referencing this project
- add events - quick notes about management meetings, or verbal discussions that don't need ticket overhead
-- meeting on 01 jan to discuss layout
-- teams message on 28 jan clarifying data requirements
- project tasks - for smaller to do items that don't warrant a full ticket
-- identify location of required data, create initial pq connections, build a mockup layout
## Reporting Timelines
- timeline view - merge ticket data with project_events sorted by date - chronological Overview
- status summaries - how many tickets open, on hold, completed
- progress tracking - sumarries or gantt style charts
--- End File: app/ui/views/project/view.html ---
--- File: app/ui/views/tag/create.html ---
Create Tag
--- End File: app/ui/views/tag/create.html ---
--- File: app/ui/views/tag/index.html ---
{{ date('Y-m-d H:i:s', strtotime(@entry.changed_at)) }} by {{ @entry.user_display_name }}
Ticket created: {{ @entry.new_value}}
Status changed
from {{ @map['status'][@entry.old_value] ?? 'Unknown' }}
to {{ @map['status'][@entry.new_value] ?? 'Unknown' }}
Priority changed
from {{ @map['priorities'][@entry.old_value] ?? 'Unknown'}}
to {{ @map['priorities'][@entry.new_value] ?? 'Unknown' }}
Assignment changed
from {{ @map['users'][@entry.old_value] ?? 'Unassigned' }}
from Unassigned
to
{{ @map['users'][@entry.new_value] ?? 'Unassigned' }}Unassigned
Title changed from "{{ @entry.old_value}}" to "{{ @entry.new_value}}".
Description updated old sha256({{@entry.old_value}}).
No history entries for this ticket.
--- End File: app/ui/views/ticket/view.html ---
--- File: app/ui/views/user/edit.html ---
{{ \CSRFHelper::field() | raw }}
--- End File: app/ui/views/user/edit.html ---
--- File: app/ui/views/user/index.html ---