diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4467968111..82cb02f3e5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,9 +3,9 @@ name: Deploy Leantime on: push: branches: - - develop - - master - workflow_dispatch: + - develop + - master + workflow_dispatch: jobs: deploy: @@ -56,7 +56,6 @@ jobs: composer install --no-dev --optimize-autoloader --no-interaction echo "Installing NPM dependencies..." - npm ci echo "Building frontend assets..." npx mix --production @@ -141,7 +140,7 @@ jobs: composer install --no-dev --optimize-autoloader --no-interaction echo "Installing NPM dependencies..." - npm ci + npm ci echo "Building frontend assets..." npx mix --production diff --git a/app/Domain/Menu/Templates/headMenu.blade.php b/app/Domain/Menu/Templates/headMenu.blade.php index ed4ecb5bff..6ff78debdd 100644 --- a/app/Domain/Menu/Templates/headMenu.blade.php +++ b/app/Domain/Menu/Templates/headMenu.blade.php @@ -240,7 +240,7 @@ class="active" >{!! __('menu.company') !!} @else settingsRepo->getSetting("projectsettings.{$notification->projectId}.slackWebhookURL"); + $ticketService = app()->make(Tickets::class); + $statusLabelsArray = $ticketService->getStatusLabels($notification->projectId); - if ($slackWebhookURL !== '' && $slackWebhookURL !== false) { + $slackWebhookURL = $this->settingsRepo->getSetting("projectsettings.{$notification->projectId}.slackWebhookURL"); + + if ($slackWebhookURL !== '' && $slackWebhookURL !== false && !is_array($notification->entity) && (str_contains(strtolower($statusLabelsArray[$notification->entity->status]['name']), 'ready to test') || str_contains(strtolower($statusLabelsArray[$notification->entity->status]['name']), 'qa')) ) { $message = $this->prepareMessage($notification); $data = [ diff --git a/app/Domain/Tickets/Controllers/ShowTicket.php b/app/Domain/Tickets/Controllers/ShowTicket.php index d252b5a825..3451ef0092 100644 --- a/app/Domain/Tickets/Controllers/ShowTicket.php +++ b/app/Domain/Tickets/Controllers/ShowTicket.php @@ -15,6 +15,7 @@ use Leantime\Domain\Timesheets\Services\Timesheets as TimesheetService; use Leantime\Domain\Users\Services\Users as UserService; use Symfony\Component\HttpFoundation\Response; +use Leantime\Domain\Setting\Services\Setting as SettingService; class ShowTicket extends Controller { @@ -39,7 +40,8 @@ public function init( FileService $fileService, CommentService $commentService, TimesheetService $timesheetService, - UserService $userService + UserService $userService, + SettingService $settingsService ): void { $this->projectService = $projectService; $this->ticketService = $ticketService; @@ -48,6 +50,7 @@ public function init( $this->commentService = $commentService; $this->timesheetService = $timesheetService; $this->userService = $userService; + $this->settingsService = $settingsService; if (session()->exists('lastPage') === false) { session(['lastPage' => BASE_URL.'/tickets/showKanban']); @@ -134,6 +137,10 @@ public function get($params): Response $this->tpl->assign('userInfo', $this->userService->getUser(session('userdata.id'))); $this->tpl->assign('users', $this->projectService->getUsersAssignedToProject($ticket->projectId)); + + $timezone = $this->settingsService->getSetting('usersettings.'.$this->userService->getUser(session('userdata.id'))['id'].'.timezone'); + $this->tpl->assign('timezone', $timezone); + $projectData = $this->projectService->getProject($ticket->projectId); $this->tpl->assign('projectData', $projectData); diff --git a/app/Domain/Tickets/Controllers/TicketHistoryController.php b/app/Domain/Tickets/Controllers/TicketHistoryController.php new file mode 100644 index 0000000000..cd2ea849fd --- /dev/null +++ b/app/Domain/Tickets/Controllers/TicketHistoryController.php @@ -0,0 +1,138 @@ +ticketHistoryModel = $ticketHistoryModel; + $this->authService = $authService; + $this->settingsService = $settingsService; + } + + public function logStatusChange(): JsonResponse + { + if (!$this->authService->loggedIn()) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Unauthorized' + ], 401); + } + + $ticketId = $_POST['ticketId'] ?? null; + $oldStatus = $_POST['oldStatus'] ?? ''; + $newStatus = $_POST['newStatus'] ?? ''; + $oldStatusText = $_POST['oldStatusText'] ?? ''; + $newStatusText = $_POST['newStatusText'] ?? ''; + $user = $_POST['user'] ?? $this->authService->getUserName(); + $detailsAttributeId = $_POST['detailsAttributeId'] ?? ''; + + if (!$ticketId) { + return new JsonResponse([ + 'success' => false, + 'error' => 'No ticket ID provided' + ], 400); + } + + try { + $insertId = $this->ticketHistoryModel->addStatusChange( + $ticketId, + $oldStatus, + $newStatus, + $oldStatusText, + $newStatusText, + $user, + $detailsAttributeId + ); + + if ($insertId) { + return new JsonResponse([ + 'success' => true, + 'message' => 'Status change logged successfully', + 'id' => $insertId + ]); + } else { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to save status change' + ], 500); + } + } catch (\Exception $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage() + ], 500); + } + } + + public function getStatusChanges(): Response + { + $ticketId = $_GET['ticketId'] ?? null; + + if (!$ticketId) { + return new Response( + json_encode(['success' => false, 'error' => 'No ticket ID provided']), + 400, + ['Content-Type' => 'application/json'] + ); + } + + try { + $changes = $this->ticketHistoryModel->getStatusChangesByTicket($ticketId); + + if (empty($changes)) { + $html = '

No status changes yet.

'; + } else { + $html = ''; + } + + return new Response($html, 200, ['Content-Type' => 'text/html']); + + } catch (\Exception $e) { + return new Response( + '

Error loading status changes: ' . htmlspecialchars($e->getMessage()) . '

', + 500, + ['Content-Type' => 'text/html'] + ); + } + } +} \ No newline at end of file diff --git a/app/Domain/Tickets/Models/TicketHistoryModel.php b/app/Domain/Tickets/Models/TicketHistoryModel.php new file mode 100644 index 0000000000..3992ab6d79 --- /dev/null +++ b/app/Domain/Tickets/Models/TicketHistoryModel.php @@ -0,0 +1,74 @@ +db = $db; + } + + public function addStatusChange($ticketId, $oldStatus, $newStatus, $oldStatusText, $newStatusText, $changedBy, $detailsAttributeId ) + { + $sql = "INSERT INTO zp_ticket_status_changes + (ticketId, oldStatus, newStatus, oldStatusText, newStatusText, changedBy, changedAt, detailsAttributeId) + VALUES (:ticketId, :oldStatus, :newStatus, :oldStatusText, :newStatusText, :changedBy, NOW(), :detailsAttributeId)"; + + $stmn = $this->db->database->prepare($sql); + $stmn->bindValue(':ticketId', $ticketId, PDO::PARAM_INT); + $stmn->bindValue(':oldStatus', $oldStatus, PDO::PARAM_STR); + $stmn->bindValue(':newStatus', $newStatus, PDO::PARAM_STR); + $stmn->bindValue(':oldStatusText', $oldStatusText, PDO::PARAM_STR); + $stmn->bindValue(':newStatusText', $newStatusText, PDO::PARAM_STR); + $stmn->bindValue(':changedBy', $changedBy, PDO::PARAM_STR); + $stmn->bindValue(':detailsAttributeId', $detailsAttributeId, PDO::PARAM_STR); + + if ($stmn->execute()) { + return $this->db->database->lastInsertId(); + } + + return false; + } + + public function getStatusChangesByTicket($ticketId) + { + $sql = "SELECT * FROM zp_ticket_status_changes + WHERE ticketId = :ticketId + ORDER BY changedAt DESC"; + + $stmn = $this->db->database->prepare($sql); + $stmn->bindValue(':ticketId', $ticketId, PDO::PARAM_INT); + $stmn->execute(); + + return $stmn->fetchAll(PDO::FETCH_ASSOC); + } + + public function getLatestStatusChange($ticketId) + { + $sql = "SELECT * FROM zp_ticket_status_changes + WHERE ticketId = :ticketId + ORDER BY changedAt DESC + LIMIT 1"; + + $stmn = $this->db->database->prepare($sql); + $stmn->bindValue(':ticketId', $ticketId, PDO::PARAM_INT); + $stmn->execute(); + + return $stmn->fetch(PDO::FETCH_ASSOC); + } + + public function deleteStatusChangesByTicket($ticketId) + { + $sql = "DELETE FROM zp_ticket_status_changes WHERE ticketId = :ticketId"; + + $stmn = $this->db->database->prepare($sql); + $stmn->bindValue(':ticketId', $ticketId, PDO::PARAM_INT); + + return $stmn->execute(); + } +} \ No newline at end of file diff --git a/app/Domain/Tickets/Repositories/TicketHistory.php b/app/Domain/Tickets/Repositories/TicketHistory.php index 14db2dbee9..ccf5e6b45e 100644 --- a/app/Domain/Tickets/Repositories/TicketHistory.php +++ b/app/Domain/Tickets/Repositories/TicketHistory.php @@ -9,9 +9,6 @@ class TicketHistory { private DbCore $db; - /** - * __construct - get database connection - */ public function __construct(DbCore $db) { $this->db = $db; diff --git a/app/Domain/Tickets/Repositories/Tickets.php b/app/Domain/Tickets/Repositories/Tickets.php index aca3fccc92..4a6f2f691e 100644 --- a/app/Domain/Tickets/Repositories/Tickets.php +++ b/app/Domain/Tickets/Repositories/Tickets.php @@ -70,7 +70,7 @@ class Tickets public array $priority = ['1' => 'Critical', '2' => 'High', '3' => 'Medium', '4' => 'Low', '5' => 'Lowest']; - public array $efforts = ['0.5' => '< 2min', '1' => 'XS', '2' => 'S', '3' => 'M', '5' => 'L', '8' => 'XL', '13' => 'XXL']; + public array $efforts = ['1' => '< 2min', '2' => 'XS', '3' => 'S', '4' => 'M', '5' => 'L', '6' => 'XL', '7' => 'XXL']; public array $type = ['task', 'subtask', 'story', 'bug']; diff --git a/app/Domain/Tickets/Services/Tickets.php b/app/Domain/Tickets/Services/Tickets.php index 9b17443576..7712fa9a8b 100644 --- a/app/Domain/Tickets/Services/Tickets.php +++ b/app/Domain/Tickets/Services/Tickets.php @@ -1828,51 +1828,136 @@ public function updateTicket($values): array|bool * * @api */ - public function patch($id, $params): bool - { +public function patch($id, $params): bool +{ + if (is_array($params)) { + unset($params['id']); + unset($params['act']); + } - // $params is an array of field names. Exclude id - if (is_array($params)) { - unset($params['id']); - unset($params['act']); - } + $ticket = $this->getTicket($id); - $ticket = $this->getTicket($id); + if (! $ticket) { + return false; + } - if (! $ticket) { - return false; + $oldValues = []; + $fieldsToLog = ['priority', 'storypoints', 'editorId']; + + foreach ($fieldsToLog as $field) { + if (isset($params[$field])) { + $oldValues[$field] = $ticket->$field ?? null; } + } - $params = $this->prepareTicketDates($params); + $params = $this->prepareTicketDates($params); - $return = $this->ticketRepository->patchTicket($id, $params); + $return = $this->ticketRepository->patchTicket($id, $params); - self::dispatchEvent('ticket_updated'); + if ($return) { + $this->logPatchChanges($id, $oldValues, $params, $ticket); + } - // Todo: create events and move notification logic to notification module - if (isset($params['status']) && $return) { - $ticket = $this->getTicket($id); - $subject = sprintf($this->language->__('email_notifications.todo_update_subject'), $id, strip_tags($ticket->headline)); - $actual_link = BASE_URL.'/dashboard/home#/tickets/showTicket/'.$id; - $message = sprintf($this->language->__('email_notifications.todo_update_message'), session('userdata.name'), strip_tags($ticket->headline)); + self::dispatchEvent('ticket_updated'); - $notification = app()->make(NotificationModel::class); - $notification->url = [ - 'url' => $actual_link, - 'text' => $this->language->__('email_notifications.todo_update_cta'), - ]; - $notification->entity = $ticket; - $notification->module = 'tickets'; - $notification->projectId = $ticket->projectId ?? session('currentProject') ?? -1; - $notification->subject = $subject; - $notification->authorId = session('userdata.id'); - $notification->message = $message; + if (isset($params['status']) && $return) { + $ticket = $this->getTicket($id); + $subject = sprintf($this->language->__('email_notifications.todo_update_subject'), $id, strip_tags($ticket->headline)); + $actual_link = BASE_URL.'/dashboard/home#/tickets/showTicket/'.$id; + $message = sprintf($this->language->__('email_notifications.todo_update_message'), session('userdata.name'), strip_tags($ticket->headline)); + + $notification = app()->make(NotificationModel::class); + $notification->url = [ + 'url' => $actual_link, + 'text' => $this->language->__('email_notifications.todo_update_cta'), + ]; + $notification->entity = $ticket; + $notification->module = 'tickets'; + $notification->projectId = $ticket->projectId ?? session('currentProject') ?? -1; + $notification->subject = $subject; + $notification->authorId = session('userdata.id'); + $notification->message = $message; - $this->projectService->notifyProjectUsers($notification); + $this->projectService->notifyProjectUsers($notification); + } + + return $return; +} + +private function logPatchChanges($ticketId, $oldValues, $newParams, $oldTicket) +{ + try { + $ticketHistoryModel = app()->make(\Leantime\Domain\Tickets\Models\TicketHistoryModel::class); + $currentUserName = session('userdata.name') ?? 'Unknown User'; + + if (isset($newParams['priority']) && isset($oldValues['priority']) && $oldValues['priority'] != $newParams['priority']) { + $priorities = $this->getPriorityLabels(); + $oldPriorityText = $priorities[$oldValues['priority']] ?? 'Priority not defined'; + $newPriorityText = $priorities[$newParams['priority']] ?? 'Priority not defined'; + + $ticketHistoryModel->addStatusChange( + $ticketId, + $oldValues['priority'], + $newParams['priority'], + $oldPriorityText, + $newPriorityText, + $currentUserName, + 'priority' + ); + } + + if (isset($newParams['storypoints']) && isset($oldValues['storypoints']) && $oldValues['storypoints'] != $newParams['storypoints']) { + $efforts = $this->getEffortLabels(); + $oldEffortText = $efforts[$oldValues['storypoints']] ?? 'Effort not defined'; + $newEffortText = $efforts[$newParams['storypoints']] ?? 'Effort not defined'; + + $ticketHistoryModel->addStatusChange( + $ticketId, + $oldValues['storypoints'], + $newParams['storypoints'], + $oldEffortText, + $newEffortText, + $currentUserName, + 'storypoints' + ); + } + + if (isset($newParams['editorId']) && isset($oldValues['editorId']) && $oldValues['editorId'] != $newParams['editorId']) { + $users = $this->ticketRepository->getUsersAssignedToProject(session('currentProject')); + + $oldEditorText = 'Not assigned'; + foreach ($users as $user) { + if ($user['id'] == $oldValues['editorId']) { + $oldEditorText = $user['firstname'] . ' ' . $user['lastname']; + break; + } + } + + $newEditorText = 'Not assigned'; + if ($newParams['editorId']) { + foreach ($users as $user) { + if ($user['id'] == $newParams['editorId']) { + $newEditorText = $user['firstname'] . ' ' . $user['lastname']; + break; + } + } + } + + $ticketHistoryModel->addStatusChange( + $ticketId, + $oldValues['editorId'], + $newParams['editorId'], + $oldEditorText, + $newEditorText, + $currentUserName, + 'editorId' + ); } - return $return; + } catch (\Exception $e) { + error_log('Failed to log patch changes: ' . $e->getMessage()); } +} /** * moveTicket - Moves a ticket from one project to another. Milestone children will be moved as well @@ -2031,29 +2116,55 @@ public function updateTicketSorting($params) */ public function updateTicketStatusAndSorting($params, $handler = null): bool { + foreach ($params as $status => $ticketList) { + if (is_numeric($status) && ! empty($ticketList)) { + $tickets = explode('&', $ticketList); - // Jquery sortable serializes the array for kanban in format - // statusKey: ticket[]=X&ticket[]=X2..., - // statusKey2: ticket[]=X&ticket[]=X2..., - // This represents status & kanban sorting - foreach ($params as $status => $ticketList) { - if (is_numeric($status) && ! empty($ticketList)) { - $tickets = explode('&', $ticketList); + if (is_array($tickets) === true) { + foreach ($tickets as $key => $ticketString) { + $id = substr($ticketString, 9); - if (is_array($tickets) === true) { - foreach ($tickets as $key => $ticketString) { - $id = substr($ticketString, 9); + try { + $oldTicket = $this->getTicket($id); + $oldStatus = $oldTicket ? $oldTicket->status : null; + } catch (\Exception $e) { + error_log('Error getting old ticket status: ' . $e->getMessage()); + $oldStatus = null; + } if ($this->ticketRepository->updateTicketStatus($id, $status, ($key * 100), $handler) === false) { return false; } + + if ($oldStatus !== null && $oldStatus != $status) { + try { + $statusLabels = $this->getStatusLabels(); + $currentUserName = session('userdata.name') ?? 'Unknown User'; + + $oldStatusText = $statusLabels[$oldStatus]['name'] ?? 'Unknown'; + $newStatusText = $statusLabels[$status]['name'] ?? 'Unknown'; + + $ticketHistoryModel = app()->make(\Leantime\Domain\Tickets\Models\TicketHistoryModel::class); + + $ticketHistoryModel->addStatusChange( + $id, + $oldStatus, + $status, + $oldStatusText, + $newStatusText, + $currentUserName, + 'kanban-board' + ); + } catch (\Exception $e) { + error_log('Failed to log kanban status change: ' . $e->getMessage()); + } } } } } + } if ($handler) { - // Assumes format ticket_ID $id = substr($handler, 7); $ticket = $this->getTicket($id); @@ -2061,7 +2172,7 @@ public function updateTicketStatusAndSorting($params, $handler = null): bool if ($ticket) { $subject = sprintf($this->language->__('email_notifications.todo_update_subject'), $id, strip_tags($ticket->headline)); $actual_link = BASE_URL.'/dashboard/home#/tickets/showTicket/'.$id; - $message = sprintf($this->language->__('email_notifications.todo_update_message'), session('userdata.name'), strip_tags($ticket->headline)); + $message = sprintf('%1$s has the ticket ready to test: \'%2$s\'', session('userdata.name'), strip_tags($ticket->headline)); $notification = app()->make(NotificationModel::class); $notification->url = [ diff --git a/app/Domain/Tickets/Templates/showKanban.tpl.php b/app/Domain/Tickets/Templates/showKanban.tpl.php index 3d89dcae7b..e920992bb5 100644 --- a/app/Domain/Tickets/Templates/showKanban.tpl.php +++ b/app/Domain/Tickets/Templates/showKanban.tpl.php @@ -99,9 +99,7 @@
displaySubmodule('tickets-ticketBoardTabs') ?> - -
- +
diff --git a/app/Domain/Tickets/Templates/submodules/ticketDetails.sub.php b/app/Domain/Tickets/Templates/submodules/ticketDetails.sub.php index dd5b592e26..ab9d0456ca 100644 --- a/app/Domain/Tickets/Templates/submodules/ticketDetails.sub.php +++ b/app/Domain/Tickets/Templates/submodules/ticketDetails.sub.php @@ -6,7 +6,10 @@ $remainingHours = $tpl->get('remainingHours'); $statusLabels = $tpl->get('statusLabels'); $ticketTypes = $tpl->get('ticketTypes'); - +$efforts = $tpl->get('efforts'); +$priorities = $tpl->get('priorities'); +$assignedUsers = $tpl->get('users'); +$timezone = $tpl->get('timezone'); ?> @@ -18,6 +21,22 @@
+ make(Users::class); + +$currentUserId = session('userdata.id') ?? null; +$currentUser = $currentUserId ? $userService->getUser($currentUserId) : null; + +if ($currentUser && isset($currentUser['firstname'], $currentUser['lastname'])) { + $currentUserName = $currentUser['firstname'] . ' ' . $currentUser['lastname']; +} else { + $currentUserName = 'Unknown User'; +} +?> + +
@@ -26,6 +45,8 @@ id="status-select" class="autosave-field" name="status" + data-old-status="status]['name'] ?>" + data-user="" data-placeholder="status) ? $statusLabels[$ticket->status]['name'] ?? '' : ''; ?>" > $label) {?> @@ -43,7 +64,8 @@ class="autosave-field"
- get('priorities') as $priorityKey => $priorityValue) { echo "