Compare commits
8 Commits
ce95e555d5
...
1989bcb8c8
| Author | SHA1 | Date | |
|---|---|---|---|
| 1989bcb8c8 | |||
| 0a2214bfaf | |||
| e7d01ef576 | |||
| a403e49537 | |||
| 06b7a8f59b | |||
| 9f1a375e5a | |||
| 84cc023bc4 | |||
| 164c2d231a |
@@ -204,6 +204,7 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
||||
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
|
||||
| `/api/assign_ticket.php` | POST | Assign ticket to user |
|
||||
| `/api/add_comment.php` | POST | Add comment to ticket |
|
||||
| `/api/clone_ticket.php` | POST | Clone an existing ticket |
|
||||
| `/api/get_template.php` | GET | Fetch ticket template |
|
||||
| `/api/get_users.php` | GET | Get user list for assignments |
|
||||
| `/api/bulk_operation.php` | POST | Perform bulk operations |
|
||||
@@ -220,6 +221,11 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
||||
| `/api/manage_recurring.php` | CRUD | Recurring tickets (admin) |
|
||||
| `/api/manage_templates.php` | CRUD | Templates (admin) |
|
||||
| `/api/manage_workflows.php` | CRUD | Workflow rules (admin) |
|
||||
| `/api/custom_fields.php` | CRUD | Custom field definitions/values (admin) |
|
||||
| `/api/saved_filters.php` | CRUD | Saved filter combinations |
|
||||
| `/api/user_preferences.php` | GET/POST | User preferences |
|
||||
| `/api/audit_log.php` | GET | Audit log entries (admin) |
|
||||
| `/api/health.php` | GET | Health check |
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -228,8 +234,12 @@ tinker_tickets/
|
||||
├── api/
|
||||
│ ├── add_comment.php # POST: Add comment
|
||||
│ ├── assign_ticket.php # POST: Assign ticket to user
|
||||
│ ├── audit_log.php # GET: Audit log entries (admin)
|
||||
│ ├── bootstrap.php # Shared auth/setup include (not a public endpoint)
|
||||
│ ├── bulk_operation.php # POST: Bulk operations (admin only)
|
||||
│ ├── check_duplicates.php # GET: Check for duplicate tickets
|
||||
│ ├── clone_ticket.php # POST: Clone an existing ticket
|
||||
│ ├── custom_fields.php # CRUD: Custom field definitions/values (admin)
|
||||
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
|
||||
│ ├── delete_comment.php # POST: Delete comment (owner/admin)
|
||||
│ ├── download_attachment.php # GET: Download with visibility check
|
||||
@@ -237,23 +247,26 @@ tinker_tickets/
|
||||
│ ├── generate_api_key.php # POST: Generate API key (admin)
|
||||
│ ├── get_template.php # GET: Fetch ticket template
|
||||
│ ├── get_users.php # GET: Get user list
|
||||
│ ├── health.php # GET: Health check endpoint
|
||||
│ ├── manage_recurring.php # CRUD: Recurring tickets (admin)
|
||||
│ ├── manage_templates.php # CRUD: Templates (admin)
|
||||
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
|
||||
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
|
||||
│ ├── saved_filters.php # CRUD: Saved filter combinations
|
||||
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
|
||||
│ ├── update_comment.php # POST: Update comment (owner/admin)
|
||||
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
|
||||
│ └── upload_attachment.php # GET/POST: List or upload attachments
|
||||
│ ├── upload_attachment.php # GET/POST: List or upload attachments
|
||||
│ └── user_preferences.php # GET/POST: User preferences
|
||||
├── assets/
|
||||
│ ├── css/
|
||||
│ │ ├── base.css # LotusGuild Terminal Design System (symlinked from web_template)
|
||||
│ │ ├── base.css # LotusGuild Terminal Design System (copied from web_template)
|
||||
│ │ ├── dashboard.css # Dashboard + terminal styling
|
||||
│ │ └── ticket.css # Ticket view styling
|
||||
│ ├── js/
|
||||
│ │ ├── advanced-search.js # Advanced search modal
|
||||
│ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
|
||||
│ │ ├── base.js # LotusGuild JS utilities — window.lt (symlinked from web_template)
|
||||
│ │ ├── base.js # LotusGuild JS utilities — window.lt (copied from web_template)
|
||||
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
|
||||
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys)
|
||||
│ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe)
|
||||
@@ -292,6 +305,8 @@ tinker_tickets/
|
||||
│ ├── UserPreferencesModel.php # User preferences
|
||||
│ └── WorkflowModel.php # Status transition workflows
|
||||
├── scripts/
|
||||
│ ├── add_closed_at_column.php # Migration: add closed_at column to tickets
|
||||
│ ├── add_comment_updated_at.php # Migration: add updated_at column to ticket_comments
|
||||
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads
|
||||
│ └── create_dependencies_table.php # Create ticket_dependencies table
|
||||
├── uploads/ # File attachment storage
|
||||
|
||||
@@ -28,6 +28,7 @@ try {
|
||||
require_once $commentModelPath;
|
||||
require_once $auditLogModelPath;
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
// Check authentication via session
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
@@ -71,6 +72,24 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verify user can access the ticket before allowing a comment
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$ticket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$ticket) {
|
||||
http_response_code(404);
|
||||
ob_end_clean();
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||
exit;
|
||||
}
|
||||
if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
http_response_code(403);
|
||||
ob_end_clean();
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Access denied']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Initialize models
|
||||
$commentModel = new CommentModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
@@ -25,9 +25,9 @@ $ticketModel = new TicketModel($conn);
|
||||
$auditLogModel = new AuditLogModel($conn);
|
||||
$userModel = new UserModel($conn);
|
||||
|
||||
// Verify ticket exists
|
||||
// Verify ticket exists and user can access it
|
||||
$ticket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$ticket) {
|
||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||
exit;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
// Only accept GET requests
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
@@ -30,6 +31,10 @@ $searchTerm = '%' . $title . '%';
|
||||
// Get SOUNDEX of title
|
||||
$soundexTitle = soundex($title);
|
||||
|
||||
// Build visibility filter so users only see titles they have access to
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$visFilter = $ticketModel->getVisibilityFilter($currentUser);
|
||||
|
||||
// First, search for exact substring matches (case-insensitive)
|
||||
$sql = "SELECT ticket_id, title, status, priority, created_at
|
||||
FROM tickets
|
||||
@@ -38,11 +43,16 @@ $sql = "SELECT ticket_id, title, status, priority, created_at
|
||||
OR SOUNDEX(title) = ?
|
||||
)
|
||||
AND status != 'Closed'
|
||||
AND ({$visFilter['sql']})
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10";
|
||||
|
||||
$types = "ss" . $visFilter['types'];
|
||||
$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']);
|
||||
$stmt = $conn->prepare($sql);
|
||||
$stmt->bind_param("ss", $searchTerm, $soundexTitle);
|
||||
if (!empty($params)) {
|
||||
$stmt->bind_param($types, ...$params);
|
||||
}
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
|
||||
@@ -74,14 +74,12 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Authorization: non-admins cannot clone internal tickets unless they created/are assigned
|
||||
if (!$isAdmin && ($sourceTicket['visibility'] ?? 'public') === 'internal') {
|
||||
if ($sourceTicket['created_by'] != $userId && $sourceTicket['assigned_to'] != $userId) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||
// Verify the user can access this ticket using centralized visibility logic
|
||||
if (!$ticketModel->canUserAccessTicket($sourceTicket, $_SESSION['user'])) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'error' => 'Source ticket not found']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare cloned ticket data
|
||||
$clonedTicketData = [
|
||||
|
||||
@@ -23,6 +23,7 @@ require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
@@ -66,7 +67,14 @@ try {
|
||||
ResponseHelper::notFound('Attachment not found');
|
||||
}
|
||||
|
||||
// Check permission
|
||||
// Verify user can access the parent ticket
|
||||
$ticketModel = new TicketModel(Database::getConnection());
|
||||
$ticket = $ticketModel->getTicketById((int)$attachment['ticket_id']);
|
||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||
ResponseHelper::notFound('Attachment not found');
|
||||
}
|
||||
|
||||
// Check permission (must be uploader or admin)
|
||||
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||
if (!$attachmentModel->canUserDelete($attachmentId, $_SESSION['user']['user_id'], $isAdmin)) {
|
||||
ResponseHelper::forbidden('You do not have permission to delete this attachment');
|
||||
|
||||
@@ -67,6 +67,7 @@ require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/DependencyModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
@@ -77,6 +78,7 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['user_id'];
|
||||
$currentUser = $_SESSION['user'];
|
||||
|
||||
// CSRF Protection for POST/DELETE
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||
@@ -99,6 +101,7 @@ if ($tableCheck->num_rows === 0) {
|
||||
try {
|
||||
$dependencyModel = new DependencyModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$ticketModel = new TicketModel($conn);
|
||||
} catch (Exception $e) {
|
||||
error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage());
|
||||
ResponseHelper::serverError('Failed to initialize required components');
|
||||
@@ -116,6 +119,12 @@ switch ($method) {
|
||||
ResponseHelper::error('Ticket ID required');
|
||||
}
|
||||
|
||||
// Verify user can access this ticket
|
||||
$ticket = $ticketModel->getTicketById((int)$ticketId);
|
||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
ResponseHelper::notFound('Ticket not found');
|
||||
}
|
||||
|
||||
try {
|
||||
$dependencies = $dependencyModel->getDependencies($ticketId);
|
||||
$dependents = $dependencyModel->getDependentTickets($ticketId);
|
||||
|
||||
+17
-2
@@ -59,14 +59,16 @@ try {
|
||||
private $workflowModel;
|
||||
private $userId;
|
||||
private $isAdmin;
|
||||
private $currentUser;
|
||||
|
||||
public function __construct($conn, $userId = null, $isAdmin = false) {
|
||||
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = []) {
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->commentModel = new CommentModel($conn);
|
||||
$this->auditLog = new AuditLogModel($conn);
|
||||
$this->workflowModel = new WorkflowModel($conn);
|
||||
$this->userId = $userId;
|
||||
$this->isAdmin = $isAdmin;
|
||||
$this->currentUser = $currentUser;
|
||||
}
|
||||
|
||||
public function update($id, $data) {
|
||||
@@ -79,6 +81,15 @@ try {
|
||||
];
|
||||
}
|
||||
|
||||
// Visibility check: return 404 for tickets the user cannot access
|
||||
if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Ticket not found',
|
||||
'http_status' => 404
|
||||
];
|
||||
}
|
||||
|
||||
// Authorization: admins can edit any ticket; others only their own or assigned
|
||||
if (!$this->isAdmin
|
||||
&& $currentTicket['created_by'] != $this->userId
|
||||
@@ -206,7 +217,7 @@ try {
|
||||
$ticketId = (int)$data['ticket_id'];
|
||||
|
||||
// Initialize controller
|
||||
$controller = new ApiTicketController($conn, $userId, $isAdmin);
|
||||
$controller = new ApiTicketController($conn, $userId, $isAdmin, $currentUser);
|
||||
|
||||
// Update ticket
|
||||
$result = $controller->update($ticketId, $data);
|
||||
@@ -215,6 +226,10 @@ try {
|
||||
ob_end_clean();
|
||||
|
||||
// Return response
|
||||
if (!empty($result['http_status'])) {
|
||||
http_response_code($result['http_status']);
|
||||
unset($result['http_status']);
|
||||
}
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($result);
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
@@ -46,7 +47,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
}
|
||||
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel(Database::getConnection());
|
||||
$conn = Database::getConnection();
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$ticket = $ticketModel->getTicketById((int)$ticketId);
|
||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||
ResponseHelper::notFound('Ticket not found');
|
||||
}
|
||||
|
||||
$attachmentModel = new AttachmentModel($conn);
|
||||
$attachments = $attachmentModel->getAttachments($ticketId);
|
||||
|
||||
// Add formatted file size and icon to each attachment
|
||||
@@ -83,6 +91,14 @@ if (!preg_match('/^\d{9}$/', $ticketId)) {
|
||||
ResponseHelper::error('Invalid ticket ID format');
|
||||
}
|
||||
|
||||
// Verify user can access the ticket before accepting upload
|
||||
$conn = Database::getConnection();
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$ticket = $ticketModel->getTicketById((int)$ticketId);
|
||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||
ResponseHelper::notFound('Ticket not found');
|
||||
}
|
||||
|
||||
// Check if file was uploaded
|
||||
if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) {
|
||||
ResponseHelper::error('No file uploaded');
|
||||
|
||||
+1
-66
@@ -1079,71 +1079,6 @@ function performBulkDelete() {
|
||||
// TERMINAL-STYLE MODAL UTILITIES
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Show a terminal-style confirmation modal
|
||||
* @param {string} title - Modal title
|
||||
* @param {string} message - Message body
|
||||
* @param {string} type - 'warning', 'error', 'info' (affects color)
|
||||
* @param {function} onConfirm - Callback when user confirms
|
||||
* @param {function} onCancel - Optional callback when user cancels
|
||||
*/
|
||||
function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
|
||||
const modalId = 'confirmModal' + Date.now();
|
||||
|
||||
// Color scheme based on type
|
||||
const colors = {
|
||||
warning: 'var(--terminal-amber)',
|
||||
error: 'var(--status-closed)',
|
||||
info: 'var(--terminal-cyan)'
|
||||
};
|
||||
const color = colors[type] || colors.warning;
|
||||
|
||||
// Icon based on type
|
||||
const icons = {
|
||||
warning: '[ ! ]',
|
||||
error: '[ X ]',
|
||||
info: '[ i ]',
|
||||
};
|
||||
const icon = icons[type] || icons.warning;
|
||||
|
||||
// Escape user-provided content to prevent XSS
|
||||
const safeTitle = lt.escHtml(title);
|
||||
const safeMessage = lt.escHtml(message);
|
||||
|
||||
const modalHtml = `
|
||||
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
|
||||
<div class="lt-modal lt-modal-sm">
|
||||
<div class="lt-modal-header" style="color: ${color};">
|
||||
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="lt-modal-body text-center">
|
||||
<p class="modal-message">${safeMessage}</p>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
|
||||
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
const modal = document.getElementById(modalId);
|
||||
lt.modal.open(modalId);
|
||||
|
||||
const cleanup = (cb) => {
|
||||
lt.modal.close(modalId);
|
||||
setTimeout(() => modal.remove(), 300);
|
||||
if (cb) cb();
|
||||
};
|
||||
|
||||
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm));
|
||||
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
|
||||
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a terminal-style input modal
|
||||
* @param {string} title - Modal title
|
||||
@@ -1413,7 +1348,7 @@ function populateKanbanCards() {
|
||||
card.innerHTML = `
|
||||
<div class="card-header">
|
||||
<span class="card-id">#${lt.escHtml(ticketId)}</span>
|
||||
<span class="card-priority p${priority}">P${priority}</span>
|
||||
<span class="lt-priority lt-p${priority}"></span>
|
||||
</div>
|
||||
<div class="card-title">${lt.escHtml(title)}</div>
|
||||
<div class="card-footer">
|
||||
|
||||
+3
-12
@@ -188,9 +188,11 @@ function addComment() {
|
||||
|
||||
commentsList.insertBefore(commentDiv, commentsList.firstChild);
|
||||
} else {
|
||||
lt.toast.error(data.error || 'Failed to add comment');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
lt.toast.error('Error adding comment: ' + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -825,7 +827,7 @@ function renderAttachments(attachments) {
|
||||
</a>
|
||||
</div>
|
||||
<div class="attachment-meta">
|
||||
${lt.escHtml(att.file_size_formatted || formatFileSize(att.file_size))} • ${lt.escHtml(uploaderName)} • ${uploadDate}
|
||||
${lt.escHtml(att.file_size_formatted || lt.bytes.format(att.file_size))} • ${lt.escHtml(uploaderName)} • ${uploadDate}
|
||||
</div>
|
||||
</div>
|
||||
<div class="attachment-actions">
|
||||
@@ -839,17 +841,6 @@ function renderAttachments(attachments) {
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes >= 1073741824) {
|
||||
return (bytes / 1073741824).toFixed(2) + ' GB';
|
||||
} else if (bytes >= 1048576) {
|
||||
return (bytes / 1048576).toFixed(2) + ' MB';
|
||||
} else if (bytes >= 1024) {
|
||||
return (bytes / 1024).toFixed(2) + ' KB';
|
||||
} else {
|
||||
return bytes + ' bytes';
|
||||
}
|
||||
}
|
||||
|
||||
function deleteAttachment(attachmentId) {
|
||||
showConfirmModal(
|
||||
|
||||
+1
-4
@@ -13,15 +13,13 @@ function getTicketIdFromUrl() {
|
||||
|
||||
/**
|
||||
* Show a terminal-style confirmation modal using the lt.modal system.
|
||||
* Falls back gracefully if dashboard.js has already defined this function.
|
||||
* @param {string} title - Modal title
|
||||
* @param {string} message - Confirmation message
|
||||
* @param {string} type - 'warning' | 'error' | 'info'
|
||||
* @param {Function} onConfirm - Called when user confirms
|
||||
* @param {Function|null} onCancel - Called when user cancels
|
||||
*/
|
||||
if (typeof showConfirmModal === 'undefined') {
|
||||
window.showConfirmModal = function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
|
||||
function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
|
||||
const modalId = 'confirmModal' + Date.now();
|
||||
const colors = { warning: 'var(--terminal-amber)', error: 'var(--status-closed)', info: 'var(--terminal-cyan)' };
|
||||
const icons = { warning: '[ ! ]', error: '[ X ]', info: '[ i ]' };
|
||||
@@ -54,5 +52,4 @@ if (typeof showConfirmModal === 'undefined') {
|
||||
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm));
|
||||
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
|
||||
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ class DashboardController {
|
||||
$totalPages = $result['pages'];
|
||||
|
||||
// Load dashboard statistics
|
||||
$stats = $this->statsModel->getAllStats();
|
||||
$stats = $this->statsModel->getAllStats($GLOBALS['currentUser'] ?? []);
|
||||
|
||||
// Load the dashboard view
|
||||
include 'views/DashboardView.php';
|
||||
|
||||
@@ -42,10 +42,10 @@ class TicketController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check visibility access
|
||||
// Check visibility access — return 404 rather than 403 to avoid leaking ticket existence
|
||||
if (!$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo "Access denied: You do not have permission to view this ticket";
|
||||
header("HTTP/1.0 404 Not Found");
|
||||
echo "Ticket not found";
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,34 @@ switch (true) {
|
||||
require_once 'api/check_duplicates.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/custom_fields.php':
|
||||
require_once 'api/custom_fields.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/saved_filters.php':
|
||||
require_once 'api/saved_filters.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/audit_log.php':
|
||||
require_once 'api/audit_log.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/user_preferences.php':
|
||||
require_once 'api/user_preferences.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/download_attachment.php':
|
||||
require_once 'api/download_attachment.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/clone_ticket.php':
|
||||
require_once 'api/clone_ticket.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/health.php':
|
||||
require_once 'api/health.php';
|
||||
break;
|
||||
|
||||
// Admin Routes - require admin privileges
|
||||
case $requestPath == '/admin/recurring-tickets':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
|
||||
+45
-13
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
class StatsModel {
|
||||
private mysqli $conn;
|
||||
@@ -173,15 +174,19 @@ class StatsModel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stats as a single array
|
||||
* Get all stats as a single array, respecting ticket visibility for the given user.
|
||||
*
|
||||
* Uses caching to reduce database load. Stats are cached for STATS_CACHE_TTL seconds.
|
||||
* Admins use a shared cache; non-admins use a per-user cache key so confidential
|
||||
* tickets are not counted in stats for users who cannot access them.
|
||||
*
|
||||
* @param array $user Current user array (must include user_id, is_admin, groups)
|
||||
* @param bool $forceRefresh Force a cache refresh
|
||||
* @return array All dashboard statistics
|
||||
*/
|
||||
public function getAllStats(bool $forceRefresh = false): array {
|
||||
$cacheKey = 'dashboard_all';
|
||||
public function getAllStats(array $user = [], bool $forceRefresh = false): array {
|
||||
$isAdmin = !empty($user['is_admin']);
|
||||
// Admins share one cache entry; non-admins get a per-user cache entry
|
||||
$cacheKey = $isAdmin ? 'dashboard_all' : 'dashboard_user_' . ($user['user_id'] ?? 'anon');
|
||||
|
||||
if ($forceRefresh) {
|
||||
CacheHelper::delete(self::CACHE_PREFIX, $cacheKey);
|
||||
@@ -190,21 +195,28 @@ class StatsModel {
|
||||
return CacheHelper::remember(
|
||||
self::CACHE_PREFIX,
|
||||
$cacheKey,
|
||||
function() {
|
||||
return $this->fetchAllStats();
|
||||
function() use ($user) {
|
||||
return $this->fetchAllStats($user);
|
||||
},
|
||||
self::STATS_CACHE_TTL
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all stats from database (uncached)
|
||||
* Fetch all stats from database (uncached), filtered by the given user's visibility.
|
||||
*
|
||||
* Uses consolidated queries to reduce database round-trips from 12 to 4.
|
||||
* Uses consolidated queries to reduce database round-trips.
|
||||
*
|
||||
* @param array $user Current user array
|
||||
* @return array All dashboard statistics
|
||||
*/
|
||||
private function fetchAllStats(): array {
|
||||
private function fetchAllStats(array $user = []): array {
|
||||
$ticketModel = new TicketModel($this->conn);
|
||||
$visFilter = $ticketModel->getVisibilityFilter($user);
|
||||
$visSQL = $visFilter['sql'];
|
||||
$visParams = $visFilter['params'];
|
||||
$visTypes = $visFilter['types'];
|
||||
|
||||
// Query 1: Get all simple counts in one query using conditional aggregation
|
||||
$countsSql = "SELECT
|
||||
SUM(CASE WHEN status IN ('Open', 'Pending', 'In Progress') THEN 1 ELSE 0 END) as open_tickets,
|
||||
@@ -216,23 +228,43 @@ class StatsModel {
|
||||
SUM(CASE WHEN priority = 1 AND status != 'Closed' THEN 1 ELSE 0 END) as critical,
|
||||
AVG(CASE WHEN status = 'Closed' AND closed_at > created_at
|
||||
THEN TIMESTAMPDIFF(HOUR, created_at, closed_at) ELSE NULL END) as avg_resolution
|
||||
FROM tickets";
|
||||
FROM tickets WHERE ($visSQL)";
|
||||
|
||||
if (!empty($visParams)) {
|
||||
$stmt = $this->conn->prepare($countsSql);
|
||||
$stmt->bind_param($visTypes, ...$visParams);
|
||||
$stmt->execute();
|
||||
$countsResult = $stmt->get_result();
|
||||
$stmt->close();
|
||||
} else {
|
||||
$countsResult = $this->conn->query($countsSql);
|
||||
}
|
||||
$counts = $countsResult->fetch_assoc();
|
||||
|
||||
// Query 2: Get priority, status, and category breakdowns in one query
|
||||
$breakdownSql = "SELECT
|
||||
'priority' as type, CONCAT('P', priority) as label, COUNT(*) as count
|
||||
FROM tickets WHERE status != 'Closed' GROUP BY priority
|
||||
FROM tickets WHERE status != 'Closed' AND ($visSQL) GROUP BY priority
|
||||
UNION ALL
|
||||
SELECT 'status' as type, status as label, COUNT(*) as count
|
||||
FROM tickets GROUP BY status
|
||||
FROM tickets WHERE ($visSQL) GROUP BY status
|
||||
UNION ALL
|
||||
SELECT 'category' as type, category as label, COUNT(*) as count
|
||||
FROM tickets WHERE status != 'Closed' GROUP BY category";
|
||||
FROM tickets WHERE status != 'Closed' AND ($visSQL) GROUP BY category";
|
||||
|
||||
if (!empty($visParams)) {
|
||||
// Need to bind params 3 times (once per UNION branch)
|
||||
$tripleParams = array_merge($visParams, $visParams, $visParams);
|
||||
$tripleTypes = $visTypes . $visTypes . $visTypes;
|
||||
$stmt = $this->conn->prepare($breakdownSql);
|
||||
$stmt->bind_param($tripleTypes, ...$tripleParams);
|
||||
$stmt->execute();
|
||||
$breakdownResult = $stmt->get_result();
|
||||
$stmt->close();
|
||||
} else {
|
||||
$breakdownResult = $this->conn->query($breakdownSql);
|
||||
}
|
||||
|
||||
$byPriority = [];
|
||||
$byStatus = [];
|
||||
$byCategory = [];
|
||||
|
||||
+31
-7
@@ -183,14 +183,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<?php if (isset($stats)): ?>
|
||||
<div class="stats-widgets">
|
||||
<div class="stats-row">
|
||||
<div class="stat-card stat-open">
|
||||
<div class="stat-card lt-stat-card stat-open" data-filter-key="status" data-filter-val="Open,Pending,In Progress" title="Click to filter by open tickets">
|
||||
<div class="stat-icon">[ # ]</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['open_tickets']; ?></div>
|
||||
<div class="stat-label">Open Tickets</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-critical">
|
||||
<div class="stat-card lt-stat-card stat-critical" data-filter-key="priority" data-filter-val="1" title="Click to filter critical tickets">
|
||||
<div class="stat-icon">[ ! ]</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['critical']; ?></div>
|
||||
@@ -211,7 +211,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<div class="stat-label">Created Today</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-resolved">
|
||||
<div class="stat-card lt-stat-card stat-resolved" data-filter-key="status" data-filter-val="Closed" title="Click to filter closed tickets">
|
||||
<div class="stat-icon">[ OK ]</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['closed_today']; ?></div>
|
||||
@@ -385,7 +385,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<table id="tickets-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
|
||||
@@ -438,12 +438,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
}
|
||||
|
||||
echo "<td><a href='/ticket/{$row['ticket_id']}' class='ticket-link'>{$row['ticket_id']}</a></td>";
|
||||
echo "<td><span>{$row['priority']}</span></td>";
|
||||
$pNum = (int)$row['priority'];
|
||||
echo "<td><span class='lt-priority lt-p{$pNum}'></span></td>";
|
||||
echo "<td>" . htmlspecialchars($row['title']) . "</td>";
|
||||
echo "<td>" . htmlspecialchars($row['category']) . "</td>";
|
||||
echo "<td>" . htmlspecialchars($row['type']) . "</td>";
|
||||
$statusSlug = htmlspecialchars(str_replace(' ', '-', $row['status']), ENT_QUOTES);
|
||||
echo "<td><span class='status-" . $statusSlug . "'>" . htmlspecialchars($row['status']) . "</span></td>";
|
||||
$statusSlug = strtolower(str_replace(' ', '-', $row['status']));
|
||||
echo "<td><span class='lt-status lt-status-{$statusSlug}'>" . htmlspecialchars($row['status']) . "</span></td>";
|
||||
echo "<td>" . htmlspecialchars($creator) . "</td>";
|
||||
echo "<td>" . htmlspecialchars($assignedTo) . "</td>";
|
||||
echo "<td class='ts-cell' data-ts='" . htmlspecialchars($row['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . date('Y-m-d H:i T', strtotime($row['created_at'])) . "'>" . date('Y-m-d H:i', strtotime($row['created_at'])) . "</td>";
|
||||
@@ -838,6 +839,29 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// Initialize lt keyboard defaults (ESC closes modals, Ctrl+K focuses search, ? shows help)
|
||||
if (window.lt) lt.keys.initDefaults();
|
||||
// Enable j/k/Enter keyboard row navigation on the ticket table
|
||||
if (window.lt) lt.tableNav.init('tickets-table');
|
||||
// Enable stat card click-to-filter using lt.statsFilter
|
||||
if (window.lt) lt.statsFilter.init();
|
||||
window.lt_onStatFilter = function(key, val) {
|
||||
const url = new URL(window.location.href);
|
||||
if (key === null) {
|
||||
// Toggle off — clear filter params
|
||||
url.searchParams.delete('status');
|
||||
url.searchParams.delete('priority_min');
|
||||
url.searchParams.delete('priority_max');
|
||||
} else if (key === 'status') {
|
||||
url.searchParams.set('status', val);
|
||||
url.searchParams.delete('priority_min');
|
||||
url.searchParams.delete('priority_max');
|
||||
} else if (key === 'priority') {
|
||||
url.searchParams.set('priority_min', val);
|
||||
url.searchParams.set('priority_max', val);
|
||||
url.searchParams.delete('status');
|
||||
}
|
||||
url.searchParams.delete('page');
|
||||
window.location.href = url.toString();
|
||||
};
|
||||
// Event delegation for all data-action handlers
|
||||
document.addEventListener('click', function(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
|
||||
Reference in New Issue
Block a user