From 683420cdb98ab73658ced4436451960f89b9152e Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 1 Jan 2026 18:57:23 -0500 Subject: [PATCH] Feature 3: Implement Status Transitions with Workflow Validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive workflow management system for ticket status transitions: - Created WorkflowModel.php for managing status transition rules - Updated TicketController.php to load allowed transitions for each ticket - Modified TicketView.php to display dynamic status dropdown with only allowed transitions - Enhanced api/update_ticket.php with server-side workflow validation - Added updateTicketStatus() JavaScript function for client-side status changes - Included CSS styling for status select dropdown with color-coded states - Transitions can require comments or admin privileges - Status changes are validated against status_transitions table This feature enforces proper ticket workflows and prevents invalid status changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- api/update_ticket.php | 34 ++++++--- assets/css/ticket.css | 57 ++++++++++++++- assets/js/ticket.js | 92 ++++++++++++++++++++++++ controllers/TicketController.php | 8 ++- models/WorkflowModel.php | 117 +++++++++++++++++++++++++++++++ views/TicketView.php | 15 +++- 6 files changed, 310 insertions(+), 13 deletions(-) create mode 100644 models/WorkflowModel.php diff --git a/api/update_ticket.php b/api/update_ticket.php index 88b3af6..47c1202 100644 --- a/api/update_ticket.php +++ b/api/update_ticket.php @@ -45,11 +45,13 @@ try { $ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php'; $commentModelPath = dirname(__DIR__) . '/models/CommentModel.php'; $auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php'; + $workflowModelPath = dirname(__DIR__) . '/models/WorkflowModel.php'; debug_log("Loading models from: $ticketModelPath and $commentModelPath"); require_once $ticketModelPath; require_once $commentModelPath; require_once $auditLogModelPath; + require_once $workflowModelPath; debug_log("Models loaded successfully"); // Check authentication via session @@ -59,22 +61,27 @@ try { } $currentUser = $_SESSION['user']; $userId = $currentUser['user_id']; - debug_log("User authenticated: " . $currentUser['username']); + $isAdmin = $currentUser['is_admin'] ?? false; + debug_log("User authenticated: " . $currentUser['username'] . " (admin: " . ($isAdmin ? 'yes' : 'no') . ")"); // Updated controller class that handles partial updates class ApiTicketController { private $ticketModel; private $commentModel; private $auditLog; + private $workflowModel; private $envVars; private $userId; + private $isAdmin; - public function __construct($conn, $envVars = [], $userId = null) { + public function __construct($conn, $envVars = [], $userId = null, $isAdmin = false) { $this->ticketModel = new TicketModel($conn); $this->commentModel = new CommentModel($conn); $this->auditLog = new AuditLogModel($conn); + $this->workflowModel = new WorkflowModel($conn); $this->envVars = $envVars; $this->userId = $userId; + $this->isAdmin = $isAdmin; } public function update($id, $data) { @@ -120,13 +127,20 @@ try { ]; } - // Validate status - $validStatuses = ['Open', 'Closed', 'In Progress', 'Pending']; - if (!in_array($updateData['status'], $validStatuses)) { - return [ - 'success' => false, - 'error' => 'Invalid status value' - ]; + // Validate status transition using workflow model + if ($currentTicket['status'] !== $updateData['status']) { + $allowed = $this->workflowModel->isTransitionAllowed( + $currentTicket['status'], + $updateData['status'], + $this->isAdmin + ); + + if (!$allowed) { + return [ + 'success' => false, + 'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status'] + ]; + } } debug_log("Validation passed, calling ticketModel->updateTicket"); @@ -286,7 +300,7 @@ try { // Initialize controller debug_log("Initializing controller"); - $controller = new ApiTicketController($conn, $envVars, $userId); + $controller = new ApiTicketController($conn, $envVars, $userId, $isAdmin); debug_log("Controller initialized"); // Update ticket diff --git a/assets/css/ticket.css b/assets/css/ticket.css index f190d6a..5018e05 100644 --- a/assets/css/ticket.css +++ b/assets/css/ticket.css @@ -515,4 +515,59 @@ body.dark-mode .timeline-content { --border-color: #444; --text-muted: #a0aec0; --text-secondary: #cbd5e0; -} \ No newline at end of file +} +/* Status select dropdown */ +.status-select { + padding: 8px 16px; + border-radius: 6px; + font-weight: 500; + text-transform: uppercase; + font-size: 0.9em; + letter-spacing: 0.5px; + border: 2px solid transparent; + cursor: pointer; + transition: all 0.2s ease; +} + +.status-select:hover { + opacity: 0.9; + border-color: rgba(255, 255, 255, 0.3); +} + +.status-select:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.5); +} + +/* Status colors for dropdown */ +.status-select.status-open { + background-color: var(--status-open) !important; + color: white !important; +} + +.status-select.status-in-progress { + background-color: var(--status-in-progress) !important; + color: #212529 !important; +} + +.status-select.status-closed { + background-color: var(--status-closed) !important; + color: white !important; +} + +.status-select.status-resolved { + background-color: #28a745 !important; + color: white !important; +} + +/* Dropdown options inherit colors */ +.status-select option { + background-color: var(--bg-primary); + color: var(--text-primary); + padding: 8px; +} + +body.dark-mode .status-select option { + background-color: #2d3748; + color: #f7fafc; +} diff --git a/assets/js/ticket.js b/assets/js/ticket.js index cc18353..afd0516 100644 --- a/assets/js/ticket.js +++ b/assets/js/ticket.js @@ -267,6 +267,98 @@ function handleAssignmentChange() { }); } +function updateTicketStatus() { + const statusSelect = document.getElementById('statusSelect'); + const selectedOption = statusSelect.options[statusSelect.selectedIndex]; + const newStatus = selectedOption.value; + const requiresComment = selectedOption.dataset.requiresComment === '1'; + const requiresAdmin = selectedOption.dataset.requiresAdmin === '1'; + + // Check if transitioning to the same status (current) + if (selectedOption.text.includes('(current)')) { + return; // No change needed + } + + // Warn if comment is required + if (requiresComment) { + const proceed = confirm(`This status change requires a comment. Please add a comment explaining the reason for this transition.\n\nProceed with status change to "${newStatus}"?`); + if (!proceed) { + // Reset to current status + statusSelect.selectedIndex = 0; + return; + } + } + + // Extract ticket ID + let ticketId; + if (window.location.href.includes('?id=')) { + ticketId = window.location.href.split('id=')[1]; + } else { + const matches = window.location.pathname.match(/\/ticket\/(\d+)/); + ticketId = matches ? matches[1] : null; + } + + if (!ticketId) { + console.error('Could not determine ticket ID'); + statusSelect.selectedIndex = 0; + return; + } + + // Update status via API + fetch('/api/update_ticket.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + ticket_id: ticketId, + status: newStatus + }) + }) + .then(response => { + if (!response.ok) { + return response.text().then(text => { + console.error('Server response:', text); + throw new Error('Network response was not ok'); + }); + } + return response.json(); + }) + .then(data => { + if (data.success) { + // Update the dropdown to show new status as current + const newClass = 'status-' + newStatus.toLowerCase().replace(/ /g, '-'); + statusSelect.className = 'editable status-select ' + newClass; + + // Update the selected option text to show as current + selectedOption.text = newStatus + ' (current)'; + + // Move the selected option to the top + statusSelect.remove(statusSelect.selectedIndex); + statusSelect.insertBefore(selectedOption, statusSelect.firstChild); + statusSelect.selectedIndex = 0; + + console.log('Status updated successfully to:', newStatus); + + // Reload page to refresh activity timeline + setTimeout(() => { + window.location.reload(); + }, 500); + } else { + console.error('Error updating status:', data.error || 'Unknown error'); + alert('Error updating status: ' + (data.error || 'Unknown error')); + // Reset to current status + statusSelect.selectedIndex = 0; + } + }) + .catch(error => { + console.error('Error updating status:', error); + alert('Error updating status: ' + error.message); + // Reset to current status + statusSelect.selectedIndex = 0; + }); +} + function showTab(tabName) { // Hide all tab contents const descriptionTab = document.getElementById('description-tab'); diff --git a/controllers/TicketController.php b/controllers/TicketController.php index f503bdd..e2670dc 100644 --- a/controllers/TicketController.php +++ b/controllers/TicketController.php @@ -4,19 +4,22 @@ require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/CommentModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/UserModel.php'; +require_once dirname(__DIR__) . '/models/WorkflowModel.php'; class TicketController { private $ticketModel; private $commentModel; private $auditLogModel; private $userModel; + private $workflowModel; private $envVars; - + public function __construct($conn) { $this->ticketModel = new TicketModel($conn); $this->commentModel = new CommentModel($conn); $this->auditLogModel = new AuditLogModel($conn); $this->userModel = new UserModel($conn); + $this->workflowModel = new WorkflowModel($conn); // Load environment variables for Discord webhook $envPath = dirname(__DIR__) . '/.env'; @@ -67,6 +70,9 @@ class TicketController { // Get all users for assignment dropdown $allUsers = $this->userModel->getAllUsers(); + // Get allowed status transitions for this ticket + $allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']); + // Load the view include dirname(__DIR__) . '/views/TicketView.php'; } diff --git a/models/WorkflowModel.php b/models/WorkflowModel.php new file mode 100644 index 0000000..f9931f4 --- /dev/null +++ b/models/WorkflowModel.php @@ -0,0 +1,117 @@ +conn = $conn; + } + + /** + * Get allowed status transitions for a given status + * + * @param string $currentStatus Current ticket status + * @return array Array of allowed transitions with requirements + */ + public function getAllowedTransitions($currentStatus) { + $sql = "SELECT to_status, requires_comment, requires_admin + FROM status_transitions + WHERE from_status = ? AND is_active = TRUE"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("s", $currentStatus); + $stmt->execute(); + $result = $stmt->get_result(); + + $transitions = []; + while ($row = $result->fetch_assoc()) { + $transitions[] = $row; + } + + $stmt->close(); + return $transitions; + } + + /** + * Check if a status transition is allowed + * + * @param string $fromStatus Current status + * @param string $toStatus Desired status + * @param bool $isAdmin Whether user is admin + * @return bool True if transition is allowed + */ + public function isTransitionAllowed($fromStatus, $toStatus, $isAdmin = false) { + // Allow same status (no change) + if ($fromStatus === $toStatus) { + return true; + } + + $sql = "SELECT requires_admin FROM status_transitions + WHERE from_status = ? AND to_status = ? AND is_active = TRUE"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("ss", $fromStatus, $toStatus); + $stmt->execute(); + $result = $stmt->get_result(); + + if ($result->num_rows === 0) { + $stmt->close(); + return false; // Transition not defined + } + + $row = $result->fetch_assoc(); + $stmt->close(); + + if ($row['requires_admin'] && !$isAdmin) { + return false; // Admin required + } + + return true; + } + + /** + * Get all possible statuses from transitions table + * + * @return array Array of unique status values + */ + public function getAllStatuses() { + $sql = "SELECT DISTINCT from_status as status FROM status_transitions + UNION + SELECT DISTINCT to_status as status FROM status_transitions + ORDER BY status"; + $result = $this->conn->query($sql); + + $statuses = []; + while ($row = $result->fetch_assoc()) { + $statuses[] = $row['status']; + } + + return $statuses; + } + + /** + * Get transition requirements + * + * @param string $fromStatus Current status + * @param string $toStatus Desired status + * @return array|null Transition requirements or null if not found + */ + public function getTransitionRequirements($fromStatus, $toStatus) { + $sql = "SELECT requires_comment, requires_admin + FROM status_transitions + WHERE from_status = ? AND to_status = ? AND is_active = TRUE"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param("ss", $fromStatus, $toStatus); + $stmt->execute(); + $result = $stmt->get_result(); + + if ($result->num_rows === 0) { + $stmt->close(); + return null; + } + + $row = $result->fetch_assoc(); + $stmt->close(); + return $row; + } +} diff --git a/views/TicketView.php b/views/TicketView.php index 0fc5467..2f462b0 100644 --- a/views/TicketView.php +++ b/views/TicketView.php @@ -182,7 +182,20 @@ function formatDetails($details, $actionType) {
- "> + ">P