*/ private array $statusLabelsByUserMemo = []; /** * Constructor method for the class. * * @param LanguageCore $language The language core instance. * @param TicketRepository $ticketRepository The ticket repository instance. * @param TimesheetRepository $timesheetsRepo The timesheet repository instance. * @param SettingRepository $settingsRepo The setting repository instance. * @param ProjectService $projectService The project service instance. * @param TimesheetService $timesheetService The timesheet service instance. * @param SprintService $sprintService The sprint service instance. * @param TicketHistory $ticketHistoryRepo The ticket history repository instance. * @param Goalcanvas $goalcanvasService The goal canvas service instance. * @param DateTimeHelper $dateTimeHelper The date time helper instance. * @param CommentService $commentService The comments service instance. * @param ClientService $clientService The clients service instance. */ public function __construct( private LanguageCore $language, private TicketRepository $ticketRepository, private TimesheetRepository $timesheetsRepo, private SettingRepository $settingsRepo, private ProjectService $projectService, private TimesheetService $timesheetService, private SprintService $sprintService, private TicketHistory $ticketHistoryRepo, private Goalcanvas $goalcanvasService, private DateTimeHelper $dateTimeHelper, private CommentService $commentService, private ClientService $clientService ) {} /** * Gets all status labels for the current set project * * @param int $projectId project id to get status labels for * @return array returns an array of status labels * * @api */ #[RequiresPermission(TicketsPermissions::VIEW, projectIdParam: 'projectId')] public function getStatusLabels($projectId = null): array { return $this->ticketRepository->getStateLabels($projectId); } /** * getAllStatusLabelsByUserId - Gets all the status labels a specific user might encounter and groups them by project. * Used to get all the status dropdowns for user home dashboards * * @params int $userId The user id * * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getAllStatusLabelsByUserId($userId): array { // Request-scoped memo: this is called repeatedly within a single dashboard // load (e.g. twice inside getToDoWidgetHierarchicalAssignments, plus the // weekly/sprint queries) and the result is stable for the request. $memoKey = $userId.'|'.(session()->exists('currentProject') ? session('currentProject') : ''); if (isset($this->statusLabelsByUserMemo[$memoKey])) { return $this->statusLabelsByUserMemo[$memoKey]; } $statusLabelsByProject = []; $userProjects = $this->projectService->getProjectsAssignedToUser($userId); if ($userProjects) { foreach ($userProjects as $project) { $statusLabelsByProject[$project['id']] = $this->ticketRepository->getStateLabels($project['id']); } } if (session()->exists('currentProject')) { $statusLabelsByProject[session('currentProject')] = $this->ticketRepository->getStateLabels(session('currentProject')); } // There is a non zero chance that a user has tickets assigned to them without a project assignment. // Checking user assigned tickets to see if there are missing projects. We only need the // distinct project ids here, so skip the (expensive) comment/file/subtask count subqueries. $allTickets = $this->ticketRepository->getAllBySearchCriteria(['currentProject' => '', 'users' => $userId, 'status' => 'not_done', 'sprint' => ''], 'duedate', null, false); foreach ($allTickets as $row) { if (! isset($statusLabelsByProject[$row['projectId']])) { $statusLabelsByProject[$row['projectId']] = $this->ticketRepository->getStateLabels($row['projectId']); } } return $this->statusLabelsByUserMemo[$memoKey] = $statusLabelsByProject; } /** * saveStatusLabels - Saves the description/label of a status * * @params array $params label information * * @api */ #[RequiresPermission(TicketsPermissions::EDIT)] public function saveStatusLabels($params): bool { if (isset($params['labelKeys']) && is_array($params['labelKeys']) && count($params['labelKeys']) > 0) { $statusArray = []; foreach ($params['labelKeys'] as $labelKey) { $labelKey = filter_var($labelKey, FILTER_SANITIZE_NUMBER_INT); $statusArray[$labelKey] = [ 'name' => $params['label-'.$labelKey] ?? '', 'class' => $params['labelClass-'.$labelKey] ?? 'label-default', 'statusType' => $params['labelType-'.$labelKey] ?? 'NEW', 'kanbanCol' => $params['labelKanbanCol-'.$labelKey] ?? false, 'sortKey' => $params['labelSort-'.$labelKey] ?? 99, ]; } StatusLabelsUpdated::dispatch( projectId: session('currentProject') ? (int) session('currentProject') : null, legacyHook: __FUNCTION__ ); Cache::forget('projectsettings.'.session('currentProject').'.ticketlabels'); return $this->settingsRepo->saveSetting('projectsettings.'.session('currentProject').'.ticketlabels', serialize($statusArray)); } return false; } /** * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getKanbanColumns(): array { $statusList = $this->ticketRepository->getStateLabels(); $visibleCols = []; foreach ($statusList as $key => $status) { if ($status['kanbanCol']) { $visibleCols[$key] = $status; } } return $visibleCols; } /** * @return array|string[] * * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getTypeIcons(): array { return $this->ticketRepository->typeIcons; } /** * @return array|string[] * * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getEffortLabels(): array { return $this->ticketRepository->efforts; } /** * @return array|string[] * * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getTicketTypes(): array { return $this->ticketRepository->type; } /** * @return array|string[] * * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getPriorityLabels(): array { return $this->ticketRepository->priority; } /** * Prepares the ticket search criteria array based on provided search parameters * and default session values. * * @param array $searchParams An associative array containing search parameters such as * 'currentProject', 'currentUser', 'users', 'status', 'term', * 'effort', 'excludeType', 'type', 'milestone', 'groupBy', * 'orderBy', 'orderDirection', 'priority', 'clients', and 'sprint'. * These values are used to filter the search results. * @return array An associative array containing the prepared search criteria. If specific * parameters are not provided, default values (often based on session data) * are used. */ public function prepareTicketSearchArray(array $searchParams): array { $searchCriteria = [ 'currentProject' => session('currentProject') ?? '', 'currentUser' => session('userdata.id') ?? '', 'currentClient' => session('userdata.clientId') ?? '', 'sprint' => session('currentSprint') ?? '', 'users' => '', 'clients' => '', 'projects' => '', 'status' => '', 'term' => '', 'effort' => '', 'type' => '', 'excludeType' => 'milestone', 'milestone' => '', 'priority' => '', 'orderBy' => 'sortIndex', 'orderDirection' => 'DESC', 'groupBy' => '', ]; // Isset is all we want to do since empty values are valid if (isset($searchParams['currentProject']) === true) { $searchCriteria['currentProject'] = $searchParams['currentProject']; } if (isset($searchParams['currentUser']) === true) { $searchCriteria['currentUser'] = $searchParams['currentUser']; } if (isset($searchParams['users']) === true) { $searchCriteria['users'] = $searchParams['users']; } if (isset($searchParams['status']) === true) { $searchCriteria['status'] = $searchParams['status']; } if (isset($searchParams['term']) === true) { $searchCriteria['term'] = $searchParams['term']; } if (isset($searchParams['effort']) === true) { $searchCriteria['effort'] = $searchParams['effort']; } if (isset($searchParams['excludeType']) === true) { $searchCriteria['excludeType'] = $searchParams['excludeType']; } if (isset($searchParams['type']) === true) { $searchCriteria['type'] = $searchParams['type']; // Give inclusion higher priority than exclusion for now $typeIn = explode(',', $searchCriteria['type']); $typeOut = explode(',', $searchCriteria['excludeType']); $typeOutFiltered = array_diff($typeOut, $typeIn); $searchCriteria['excludeType'] = implode(',', $typeOutFiltered); } if (isset($searchParams['milestone']) === true) { $searchCriteria['milestone'] = $searchParams['milestone']; } if (isset($searchParams['groupBy']) === true) { $searchCriteria['groupBy'] = $searchParams['groupBy']; } if (isset($searchParams['orderBy']) === true) { $searchCriteria['orderBy'] = $searchParams['orderBy']; } if (isset($searchParams['orderDirection']) === true) { $searchCriteria['orderDirection'] = $searchParams['orderDirection']; } if (isset($searchParams['priority']) === true) { $searchCriteria['priority'] = $searchParams['priority']; } if (isset($searchParams['clients']) === true) { $searchCriteria['clients'] = $searchParams['clients']; } // Multi-project filter (comma separated project ids). Used by program-level // cross-project task views. When set it takes precedence over the single // currentProject filter in the repository (see getAllBySearchCriteria). if (isset($searchParams['projects']) === true) { $searchCriteria['projects'] = $searchParams['projects']; } // The sprint selector is just a filter but remains in place across the session. Setting session here when it's selected if (isset($searchParams['sprint']) === true) { $searchCriteria['sprint'] = $searchParams['sprint']; session(['currentSprint' => $searchCriteria['sprint']]); } return $searchCriteria; } /** * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function countSetFilters(array $searchCriteria): int { $count = 0; $setFilters = []; foreach ($searchCriteria as $key => $value) { if ( $key != 'groupBy' && $key != 'currentProject' && $key != 'orderBy' && $key != 'currentUser' && $key != 'currentClient' && $key != 'sprint' && $key != 'orderDirection' ) { if ($value != '') { $count++; $setFilters[$key] = $value; } } } return $count; } /** * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getSetFilters(array $searchCriteria, bool $includeGroup = false): array { $setFilters = []; foreach ($searchCriteria as $key => $value) { if ( $key != 'currentProject' && $key != 'orderBy' && $key != 'currentUser' && $key != 'clients' && $key != 'sprint' && $key != 'orderDirection' ) { if ($includeGroup === true && $key == 'groupBy' && $value != '') { $setFilters[$key] = $value; } elseif ($value != '') { $setFilters[$key] = $value; } } } return $setFilters; } /** * Retrieves all tickets based on the provided search criteria. * * @param array|null $searchCriteria An associative array containing search parameters such as * 'currentProject', 'currentUser', 'users', 'status', 'term', * 'effort', 'excludeType', 'type', 'milestone', 'groupBy', * 'orderBy', 'orderDirection', 'priority', 'clients', and 'sprint'. * These values are used to filter the search results. * @return array|false An array of tickets matching the search criteria, or false on failure. * * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getAll(?array $searchCriteria = null, ?int $limit = null): array|false { if (isset($searchCriteria['dateFrom'])) { try { $searchCriteria['dateFrom'] = dtHelper()->parseUserDateTime($searchCriteria['dateFrom']); } catch (\Exception $e) { Log::warning('Tickets::getAll: Could not parse dateFrom: '.$searchCriteria['dateFrom'].''); } } if (isset($searchCriteria['dateTo'])) { try { $searchCriteria['dateTo'] = dtHelper()->parseUserDateTime($searchCriteria['dateTo']); } catch (\Exception $e) { Log::warning('Tickets::getAll: Could not parse dateTo: '.$searchCriteria['dateTo'].''); } } $tickets = $this->ticketRepository->getAllBySearchCriteria( searchCriteria: $searchCriteria ?? [], sort: $searchCriteria['orderBy'] ?? 'date', includeCounts: false, limit: $limit ); if (is_array($tickets)) { $tickets = $this->decorateWithFriendlyStatusLabels($tickets); } return $tickets; } private function decorateWithFriendlyStatusLabels(array $tickets): array { if (is_array($tickets)) { $ticketCounter = 0; $projectStatusLabels = []; foreach ($tickets as &$ticket) { if (! isset($projectStatusLabels[$ticket['projectId']])) { $projectStatusLabels[$ticket['projectId']] = $this->ticketRepository->getStateLabels($ticket['projectId']); } if (isset($projectStatusLabels[$ticket['projectId']][$ticket['status']]) && $projectStatusLabels[$ticket['projectId']][$ticket['status']]['statusType'] !== 'DONE') { $ticket['statusLabel'] = $projectStatusLabels[$ticket['projectId']][$ticket['status']]['name']; } } } return $tickets; } public function simpleTicketCounter(?int $userId = null, ?int $project = null, string $status = '', array $types = []): int { $tickets = $this->ticketRepository->simpleTicketQuery($userId, $project, $types); if ($status != '' && is_array($tickets)) { $ticketCounter = 0; $projectStatusLabels = []; foreach ($tickets as $ticket) { if (! isset($projectStatusLabels[$ticket['projectId']])) { $projectStatusLabels[$ticket['projectId']] = $this->ticketRepository->getStateLabels($ticket['projectId']); } if ( $status == 'not_done' && ( ! isset($projectStatusLabels[$ticket['projectId']][$ticket['status']]) || $projectStatusLabels[$ticket['projectId']][$ticket['status']]['statusType'] !== 'DONE' ) ) { $ticketCounter++; continue; } if ( isset($projectStatusLabels[$ticket['projectId']][$ticket['status']]['statusType']) && $projectStatusLabels[$ticket['projectId']][$ticket['status']]['statusType'] == $status ) { $ticketCounter++; } } return $ticketCounter; } if (is_array($tickets)) { return count($tickets); } return 0; } /** * Retrieves all open user tickets, optionally filtered by user ID and project. * * @param int|null $userId The user ID to filter tickets. If null, tickets are not filtered by user. * @param int|null $project The project ID to filter tickets. If null, tickets are not filtered by project. * @return array An array of open user tickets with relevant details such as status labels. * * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getAllOpenUserTickets(?int $userId = null, ?int $project = null): array { // Exclude closed projects (state === -1) at the SQL level — "My open // tickets" shouldn't surface work from projects that are no longer // active (they were padding the mobile task list). $tickets = $this->ticketRepository->simpleTicketQuery($userId, $project, excludeClosedProjects: true); $ticketArray = []; if (is_array($tickets)) { $ticketCounter = 0; $projectStatusLabels = []; foreach ($tickets as $ticket) { if ($ticket['type'] !== 'milestone') { if (! isset($projectStatusLabels[$ticket['projectId']])) { $projectStatusLabels[$ticket['projectId']] = $this->ticketRepository->getStateLabels($ticket['projectId']); } if (isset($projectStatusLabels[$ticket['projectId']][$ticket['status']]) && $projectStatusLabels[$ticket['projectId']][$ticket['status']]['statusType'] !== 'DONE') { // Ship the resolved status label, class, and type // so mobile (which doesn't preload each project's // status config) can render the correct label // and colour without an extra round trip per // project. Web doesn't need these because it // already has the project config loaded // server-side at render time. $statusConfig = $projectStatusLabels[$ticket['projectId']][$ticket['status']]; $ticket['statusLabel'] = $statusConfig['name']; $ticket['statusClass'] = $statusConfig['class'] ?? ''; $ticket['statusType'] = $statusConfig['statusType'] ?? ''; $ticketArray[] = $ticket; } } } } return $ticketArray; } /** * Retrieves scheduled tasks within a given date range and optionally filters by user ID. * * @param CarbonImmutable $dateFrom The start date and time, expected in UTC. * @param CarbonImmutable $dateTo The end date and time, expected in UTC. * @param int|null $userId Optional user ID to filter the tasks. * @return array Returns an associative array containing the following keys: * - 'totalTasks': An array of all scheduled tasks within the date range. * - 'doneTasks': An array of tasks marked as completed (DONE status). * * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getScheduledTasks(CarbonImmutable|string $dateFrom, CarbonImmutable|string $dateTo, ?int $userId) { if (is_string($dateFrom) && dtHelper()->isValidDateString($dateFrom)) { $dateFrom = dtHelper()->parseUserDateTime($dateFrom); } if (is_string($dateTo) && dtHelper()->isValidDateString($dateTo)) { $dateTo = dtHelper()->parseUserDateTime($dateTo); } $totalTasks = $this->ticketRepository->getScheduledTasks($dateFrom, $dateTo, $userId); $statusLabels = []; $doneTasks = []; foreach ($totalTasks as &$ticket) { if (! isset($statusLabels[$ticket['projectId']])) { $statusLabels[$ticket['projectId']] = $this->ticketRepository->getStateLabels($ticket['projectId']); } if (isset($statusLabels[$ticket['projectId']][$ticket['status']])) { $ticket['statusLabel'] = $statusLabels[$ticket['projectId']][$ticket['status']]['name']; } else { $ticket['statusLabel'] = 'Unknown'; } if (isset($statusLabels[$ticket['projectId']][$ticket['status']]) && $statusLabels[$ticket['projectId']][$ticket['status']]['statusType'] == 'DONE') { $doneTasks[] = $ticket; } } return ['totalTasks' => $totalTasks, 'doneTasks' => $doneTasks]; } /** * @throws BindingResolutionException * * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getAllGrouped($searchCriteria): array { $ticketGroups = []; $tickets = $this->ticketRepository->getAllBySearchCriteria( $searchCriteria, $searchCriteria['orderBy'] ?? 'date' ); if ( $searchCriteria['groupBy'] == null || $searchCriteria['groupBy'] == '' || $searchCriteria['groupBy'] == 'all' ) { $ticketGroups['all'] = [ 'label' => 'all', 'id' => 'all', 'value' => '', 'class' => '', 'items' => $tickets, ]; return $ticketGroups; } // Special handling for due date grouping (computed buckets, not direct field values) if ($searchCriteria['groupBy'] == 'dueDate') { return $this->groupTicketsByDueDate($tickets); } // Resolve root parents so sub-tasks of sub-tasks group under the top-level parent if ($searchCriteria['groupBy'] === 'dependingTicketId') { $tickets = $this->resolveRootParents($tickets); } $groupByOptions = $this->getGroupByFieldOptions(); foreach ($tickets as $ticket) { $class = ''; $moreInfo = ''; $groupColor = ''; $sortId = null; // Custom sort ID, defaults to groupedFieldValue if null if (isset($ticket[$searchCriteria['groupBy']]) || ($searchCriteria['groupBy'] === 'dependingTicketId' && array_key_exists('dependingTicketId', $ticket)) ) { $groupedFieldValue = strtolower((string) ($ticket[$searchCriteria['groupBy']] ?? '0')); if (isset($ticketGroups[$groupedFieldValue])) { $ticketGroups[$groupedFieldValue]['items'][] = $ticket; } else { switch ($searchCriteria['groupBy']) { case 'status': $status = $this->getStatusLabels(); if (isset($status[$groupedFieldValue])) { $label = $status[$groupedFieldValue]['name']; $class = $status[$groupedFieldValue]['class']; } else { $label = 'New'; } break; case 'priority': $priorities = $this->getPriorityLabels(); if (isset($priorities[$groupedFieldValue])) { $label = $priorities[$groupedFieldValue]; $class = 'priority-text-'.$groupedFieldValue; } else { $label = 'No Priority Set'; $sortId = '999'; // Sort "No Priority" after Lowest (5) } break; case 'storypoints': $efforts = $this->getEffortLabels(); $label = $efforts[$groupedFieldValue] ?? 'No Effort Set'; // For descending sort: subtract from 100 so higher values sort first // No effort (0 or empty) gets 999 to sort last if (empty($groupedFieldValue) || $groupedFieldValue == '0') { $sortId = '999'; } else { $sortId = str_pad((string) (100 - (float) $groupedFieldValue), 6, '0', STR_PAD_LEFT); } break; case 'milestoneid': $label = 'No Milestone Set'; $sortId = 'zzz_no_milestone'; // Sort "No Milestone" last alphabetically // getTicket() returns false when the current user can't access the // milestone's project (e.g. a cross-project milestone linked to a goal). // Fall back to the "No Milestone Set" default rather than dereferencing false. $milestone = $ticket['milestoneid'] > 0 ? $this->getTicket($ticket['milestoneid']) : false; if ($milestone) { $color = $milestone->tags; $class = ''; $groupColor = $color; try { $startDate = dtHelper()->parseDbDateTime($milestone->editFrom)->formatDateForUser(); } catch (\Exception $e) { $startDate = $this->language->__('text.no_date_defined'); } try { $endDate = dtHelper()->parseDbDateTime($milestone->editTo)->formatDateForUser(); } catch (\Exception $e) { $endDate = $this->language->__('text.no_date_defined'); } $statusLabels = $this->getStatusLabels($milestone->projectId); $status = $statusLabels[$milestone->status]['name']; $moreInfo = $this->language->__('label.start').': '.$startDate.' • '.$this->language->__('label.end').': '.$endDate.' • '.$this->language->__('label.status_lowercase').': '.$status; $label = $ticket['milestoneHeadline']; $sortId = 'a_'.preg_replace('/[^a-zA-Z0-9_-]/', '_', $ticket['milestoneHeadline']); // Named milestones sort first alphabetically } break; case 'editorId': $label = "
".$ticket['editorFirstname'].' '.$ticket['editorLastname']; if ($ticket['editorFirstname'] == '' && $ticket['editorLastname'] == '') { $label = 'Not Assigned to Anyone'; } break; case 'sprint': // Rendered raw ({!! !!}) in the kanban swimlane header, so escape the // user-controlled sprint name to prevent stored XSS. $label = htmlspecialchars((string) $ticket['sprintName'], ENT_QUOTES, 'UTF-8'); if ($label == '') { $label = 'Not assigned to a sprint'; } break; case 'type': $icon = $this->getTypeIcons(); $label = "".$ticket['type']; break; case 'dependingTicketId': if ($ticket['dependingTicketId'] > 0 && ! empty($ticket['parentHeadline'])) { // Rendered raw in the swimlane header — escape the user headline. $label = htmlspecialchars((string) $ticket['parentHeadline'], ENT_QUOTES, 'UTF-8'); $sortId = 'a_'.preg_replace('/[^a-zA-Z0-9_-]/', '_', strtolower((string) $ticket['parentHeadline'])); } else { $label = $this->language->__('label.no_parent_task'); $sortId = 'zzz_no_parent'; } break; case 'projectId': // Program cross-project board: group by the ticket's project. $label = $ticket['projectName'] ?? ('Project #'.$groupedFieldValue); $sortId = 'a_'.strtolower((string) ($ticket['projectName'] ?? $groupedFieldValue)); break; default: $label = htmlspecialchars((string) $groupedFieldValue, ENT_QUOTES, 'UTF-8'); break; } $ticketGroups[$groupedFieldValue] = [ 'label' => $label, 'more-info' => $moreInfo, 'id' => $sortId ?? strtolower($groupedFieldValue), 'value' => $groupedFieldValue, 'class' => $class, 'color' => $groupColor, 'items' => [$ticket], ]; } } } // Sort main groups by appropriate field switch ($searchCriteria['groupBy']) { case 'status': case 'priority': case 'storypoints': case 'milestoneid': case 'dependingTicketId': // Sort by ID for ordered fields (named milestones first, "No Milestone" last) $ticketGroups = array_sort($ticketGroups, 'id'); break; default: // Sort alphabetically by label for other groupings $ticketGroups = array_sort($ticketGroups, 'label'); break; } return $ticketGroups; } /** * Resolve root parents for each ticket by walking up the parent chain. * * Ensures sub-tasks of sub-tasks are grouped under the top-level parent * rather than their immediate parent. For example, if C -> B -> A, * both B and C will have their dependingTicketId set to A's id. * * @param array> $tickets * @return array> */ private function resolveRootParents(array $tickets): array { // Build a lookup map of ticket IDs to their parent and headline $ticketMap = []; foreach ($tickets as $ticket) { $ticketMap[(int) $ticket['id']] = [ 'dependingTicketId' => $ticket['dependingTicketId'] ?? null, 'headline' => $ticket['headline'] ?? '', ]; } foreach ($tickets as $key => $ticket) { if (empty($ticket['dependingTicketId']) || $ticket['dependingTicketId'] <= 0) { continue; } $currentId = (int) $ticket['dependingTicketId']; $visited = [(int) $ticket['id']]; while (true) { if (in_array($currentId, $visited)) { break; // Stop walking if we detect a circular reference } $visited[] = $currentId; // Check if this parent also has a parent of its own $parentInfo = null; if (isset($ticketMap[$currentId])) { $parentInfo = $ticketMap[$currentId]; } else { $parentTicket = $this->getTicket($currentId); if ($parentTicket !== false) { $parentInfo = [ 'dependingTicketId' => $parentTicket->dependingTicketId, 'headline' => $parentTicket->headline, ]; $ticketMap[$currentId] = $parentInfo; } } if ($parentInfo && ! empty($parentInfo['dependingTicketId']) && (int) $parentInfo['dependingTicketId'] > 0) { $currentId = (int) $parentInfo['dependingTicketId']; continue; } // This ticket has no parent so it is the root break; } // Point the ticket at the root parent instead of its immediate parent if ($currentId !== (int) $ticket['dependingTicketId']) { $tickets[$key]['dependingTicketId'] = $currentId; $tickets[$key]['parentHeadline'] = $ticketMap[$currentId]['headline'] ?? ''; } } return $tickets; } /** * Group tickets by due date into time-based buckets * * Buckets (in order): * 1. Overdue - due_date < today * 2. Due This Week - 0-6 days from today (includes today) * 3. Due Next Week - 7-13 days from today * 4. Due Later - 14+ days from today * 5. No Due Date - null/empty due date * * @param array $tickets Array of ticket data * @return array Grouped tickets by due date bucket */ private function groupTicketsByDueDate(array $tickets): array { // Define buckets in display order with sort IDs $bucketDefinitions = [ 'overdue' => [ 'label' => 'Overdue', 'id' => '0', 'class' => '', ], 'due-this-week' => [ 'label' => 'Due This Week', 'id' => '1', 'class' => '', ], 'due-next-week' => [ 'label' => 'Due Next Week', 'id' => '2', 'class' => '', ], 'due-later' => [ 'label' => 'Due Later', 'id' => '3', 'class' => '', ], 'no-due-date' => [ 'label' => 'No Due Date', 'id' => '4', 'class' => '', ], ]; // Initialize all buckets with empty items (so empty buckets still display) $ticketGroups = []; foreach ($bucketDefinitions as $bucketKey => $bucketDef) { $ticketGroups[$bucketKey] = [ 'label' => $bucketDef['label'], 'id' => $bucketDef['id'], 'value' => $bucketKey, 'class' => $bucketDef['class'], 'more-info' => '', 'items' => [], ]; } // Get today's date at midnight in user's timezone $today = CarbonImmutable::now()->startOfDay(); // Assign each ticket to appropriate bucket foreach ($tickets as $ticket) { $bucketKey = $this->getDueDateBucket($ticket['dateToFinish'] ?? null, $today); $ticketGroups[$bucketKey]['items'][] = $ticket; } // Sort tickets within each bucket by due date (earliest first) // For "No Due Date" bucket, sort by creation date (oldest first) foreach ($ticketGroups as $bucketKey => &$group) { if ($bucketKey === 'no-due-date') { // Sort by creation date (oldest first) usort($group['items'], function ($a, $b) { $dateA = $a['date'] ?? ''; $dateB = $b['date'] ?? ''; return strcmp($dateA, $dateB); }); } else { // Sort by due date (earliest first) usort($group['items'], function ($a, $b) { $dateA = $a['dateToFinish'] ?? ''; $dateB = $b['dateToFinish'] ?? ''; return strcmp($dateA, $dateB); }); } } unset($group); return $ticketGroups; } /** * Determine which due date bucket a ticket belongs to * * @param string|null $dateToFinish The ticket's due date * @param CarbonImmutable $today Today's date at midnight * @return string The bucket key */ private function getDueDateBucket(?string $dateToFinish, CarbonImmutable $today): string { // Handle null/empty/invalid due dates if (empty($dateToFinish) || str_starts_with($dateToFinish, '0000-00-00')) { return 'no-due-date'; } try { $dueDate = CarbonImmutable::parse($dateToFinish)->startOfDay(); } catch (\Exception $e) { return 'no-due-date'; } $diffDays = $today->diffInDays($dueDate, false); // false = signed difference if ($diffDays < 0) { return 'overdue'; } if ($diffDays <= 6) { return 'due-this-week'; // 0-6 days (includes today) } if ($diffDays <= 13) { return 'due-next-week'; // 7-13 days } return 'due-later'; // 14+ days } /** * Get status breakdown counts for grouped tickets * * Calculates ticket counts per status column for each swimlane group. * This is used to populate status breakdown visualizations like progress bars. * * @param array $groupedTickets - Result from getAllGrouped() * @param array $statusColumns - Result from getKanbanColumns() * @return array Status counts per swimlane with structure: * [ * 'groupId' => [ * 'statusCounts' => ['status_id' => count, ...], * 'totalCount' => int, * 'label' => string, * 'id' => string, * 'class' => string, * 'moreInfo' => string * ] * ] * * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getStatusBreakdownBySwimlane(array $groupedTickets, array $statusColumns, string $statusField = 'status'): array { $breakdown = []; foreach ($groupedTickets as $groupId => $group) { $statusCounts = []; $totalCount = 0; // Initialize all status columns to 0 (use string keys for consistency) foreach ($statusColumns as $statusId => $statusLabel) { $statusCounts[(string) $statusId] = 0; } // Count tickets by status and determine time alert $hasOverdue = false; $hasDueSoon = false; $allStale = true; $now = CarbonImmutable::now(); foreach ($group['items'] as $ticket) { // $statusField is 'statusType' on the program (cross-project) kanban, where // columns are semantic status types rather than per-project status keys. $status = (string) ($ticket[$statusField] ?? ''); if (isset($statusCounts[$status])) { $statusCounts[$status]++; $totalCount++; } // Time alert logic // Check for overdue (highest priority) if (isset($ticket['dateToFinish']) && ! empty($ticket['dateToFinish'])) { $dueDate = CarbonImmutable::parse($ticket['dateToFinish']); if ($dueDate->isPast()) { $hasOverdue = true; } elseif ($dueDate->diffInDays($now) <= 3) { $hasDueSoon = true; } } // Check for stale (no activity for 14+ days) if (isset($ticket['editedDate']) && ! empty($ticket['editedDate'])) { $lastActivity = CarbonImmutable::parse($ticket['editedDate']); if ($lastActivity->diffInDays($now) < 14) { $allStale = false; } } } // Determine which time alert to show (priority: overdue > dueSoon > stale) $timeAlert = null; if ($hasOverdue) { $timeAlert = 'overdue'; } elseif ($hasDueSoon) { $timeAlert = 'dueSoon'; } elseif ($allStale && $totalCount > 0) { $timeAlert = 'stale'; } // Use string version of group['id'] as key for consistent lookup in template $breakdown[(string) $group['id']] = [ 'statusCounts' => $statusCounts, 'totalCount' => $totalCount, 'label' => $group['label'], 'id' => $group['id'], 'class' => $group['class'] ?? '', 'moreInfo' => $group['more-info'] ?? '', 'timeAlert' => $timeAlert, ]; } return $breakdown; } /** * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getAllPossibleParents(TicketModel $ticket, string $projectId = 'currentProject'): array { if ($projectId == 'currentProject') { $projectId = session('currentProject'); } $results = $this->ticketRepository->getAllPossibleParents($ticket, $projectId); if (is_array($results)) { return $results; } else { return []; } } /** * Retrieves a ticket based on its ID if the user has access to the associated project. * * @param int|string $id The ID of the ticket to retrieve. * @return TicketModel|bool The ticket object if found and accessible, or false otherwise. * * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getTicket($id): TicketModel|bool { $ticket = $this->ticketRepository->getTicket($id); // Check if user is allowed to see ticket if ($ticket && $this->projectService->isUserAssignedToProject(session('userdata.id'), $ticket->projectId)) { return $ticket; } return false; } /** * Whether the current user holds at least the given role IN a specific project. * * Leantime roles are project-scoped: Auth::userIsAtLeast() evaluates the role * for the current *session* project, so it can't be trusted to authorize an * action on an entity that lives in a different project. This resolves the * user's effective role for $projectId — managers/admins/owners keep their * global role across every project; otherwise the project role applies, * falling back to the global role when no explicit project role is set — and * compares it against the required role using the same ordering as Roles. * * @param string $role Minimum role (a Roles::$* string). * @param int $projectId The project that owns the entity being changed. */ private function userIsAtLeastForProject(string $role, int $projectId): bool { $roles = Roles::getRoles(); $globalRole = session('userdata.role'); $globalKey = array_search($globalRole, $roles, true); $managerKey = array_search(Roles::$manager, $roles, true); // Manager+ (manager, admin, owner) keep their global role everywhere. if ($globalKey !== false && $managerKey !== false && $globalKey >= $managerKey) { $effectiveRole = $globalRole; } else { $projectRole = $this->projectService->getProjectRole(session('userdata.id'), $projectId); // No explicit project role -> inherit the global role. $effectiveRole = $projectRole === '' ? $globalRole : Roles::getRoleString((int) $projectRole); } $requiredKey = array_search($role, $roles, true); $effectiveKey = array_search($effectiveRole, $roles, true); return $requiredKey !== false && $effectiveKey !== false && $effectiveKey >= $requiredKey; } /** * @api */ #[RequiresPermission(TicketsPermissions::VIEW, projectIdParam: 'projectId')] public function getLastTickets($projectId, int $limit = 5): bool|array { $searchCriteria = $this->prepareTicketSearchArray(['currentProject' => $projectId, 'users' => '', 'status' => 'not_done', 'sprint' => '', 'limit' => $limit]); $allTickets = $this->ticketRepository->getAllBySearchCriteria($searchCriteria, 'date', $limit); // Get status labels for the project $statusLabels = $this->getStatusLabels($projectId); // Add status label to each ticket if (is_array($allTickets)) { foreach ($allTickets as &$ticket) { if (isset($statusLabels[$ticket['status']])) { $ticket['statusLabel'] = $statusLabels[$ticket['status']]['name']; } else { $ticket['statusLabel'] = 'Unknown'; } } } return $allTickets; } /** * Retrieves the open tickets assigned to a user that are due this week and later, optionally * narrowed to a single project, with optional inclusion of completed tickets and milestones. * * This is a "my work" view (filtered by $userId). $projectId is optional: pass a project id to * narrow to it (the dispatch gate then runs the per-project membership check), or omit it / pass * 0 for the cross-project view mobile's Tasks tab uses. When no concrete project resolves, the * enforcer has no project to scope to (the session project is null on Bearer), so tickets.view * is evaluated against the user's global role — a user can always see their own assigned * tickets. A mandatory $projectId here previously fail-closed (-32001 on 0, -32602 when omitted) * for every role, breaking the mobile Tasks tab. * * @param int $userId The ID of the user whose tickets are to be retrieved. * @param int|string|null $projectId Optional project to narrow to; null/0/'' = across all the user's projects. * @param bool $includeDoneTickets Whether to include tickets marked as done. Default is false. * @param bool $includeMilestones Whether to include milestones in the results. Default is false. * @return array Returns an array of grouped tickets categorized by their due date (e.g., * overdue, this week, later). * * @api */ #[RequiresPermission(TicketsPermissions::VIEW, projectIdParam: 'projectId')] public function getOpenUserTicketsThisWeekAndLater($userId, $projectId = null, bool $includeDoneTickets = false, bool $includeMilestones = false, ?int $limit = null, ?int $offset = null, ?string $group = null): array { if ($includeDoneTickets === true) { $searchStatus = 'all'; } else { $searchStatus = 'not_done'; } $searchCriteria = $this->prepareTicketSearchArray(['currentProject' => $projectId, 'currentUser' => $userId, 'users' => $userId, 'status' => $searchStatus, 'sprint' => '']); if ($includeMilestones) { $searchCriteria['excludeType'] = ''; } $allTickets = $this->ticketRepository->getAllBySearchCriteria( searchCriteria: $searchCriteria, sort: 'duedate', limit: $limit, includeCounts: false, offset: $offset); $statusLabels = $this->getAllStatusLabelsByUserId($userId); $tickets = []; foreach ($allTickets as $row) { // There is a non zero chance that a user has tasks assigned to them while not being part of the project // Need to get those status labels as well if (! isset($statusLabels[$row['projectId']])) { $statusLabels[$row['projectId']] = $this->ticketRepository->getStateLabels($row['projectId']); } // There is a chance that the status was removed after it was assigned to a ticket if (isset($statusLabels[$row['projectId']][$row['status']]) && ($statusLabels[$row['projectId']][$row['status']]['statusType'] != 'DONE' || $includeDoneTickets === true)) { if ($row['dateToFinish'] == '0000-00-00 00:00:00' || $row['dateToFinish'] == '1969-12-31 00:00:00' || $row['dateToFinish'] == null) { if (isset($tickets['later']['tickets'])) { $tickets['later']['tickets'][] = $row; } else { $tickets['later'] = [ 'labelName' => 'subtitles.due_later', 'groupValue' => '', 'tickets' => [$row], 'order' => 3, ]; } } else { $today = dtHelper()->userNow()->setToDbTimezone(); try { $dbDueDate = dtHelper()->parseDbDateTime($row['dateToFinish']); } catch (\Exception $e) { Log::warning('Error in DB Due date parsing: '.$e->getMessage()); $dbDueDate = dtHelper()->userNow()->addYears(); } $nextFriday = dtHelper()->userNow()->endOfWeek(CarbonInterface::FRIDAY)->setToDbTimezone(); if ($dbDueDate <= $nextFriday && $dbDueDate >= $today) { if (isset($tickets['thisWeek']['tickets'])) { $tickets['thisWeek']['tickets'][] = $row; } else { $tickets['thisWeek'] = [ 'labelName' => 'subtitles.due_this_week', 'tickets' => [$row], 'groupValue' => $dbDueDate->formatDateTimeForDb(), 'order' => 2, ]; } } elseif ($dbDueDate <= $today) { if (isset($tickets['overdue']['tickets'])) { $tickets['overdue']['tickets'][] = $row; } else { $tickets['overdue'] = [ 'labelName' => 'subtitles.overdue', 'tickets' => [$row], 'groupValue' => $dbDueDate->formatDateTimeForDb(), 'order' => 1, ]; } } else { if (isset($tickets['later']['tickets'])) { $tickets['later']['tickets'][] = $row; } else { $tickets['later'] = [ 'labelName' => 'subtitles.due_later', 'tickets' => [$row], 'groupValue' => '', 'order' => 3, ]; } } } } } // $ticketsSorted = array_sort($tickets, 'order'); return $tickets; } /** * @api */ #[RequiresPermission(TicketsPermissions::VIEW, projectIdParam: 'projectId')] public function getOpenUserTicketsByProject($userId, $projectId = null, bool $includeMilestones = false, ?int $limit = null, ?int $offset = null, ?string $group = null): array { $searchCriteria = $this->prepareTicketSearchArray(['currentProject' => $projectId, 'users' => $userId, 'status' => '', 'sprint' => '']); if ($includeMilestones) { $searchCriteria['excludeType'] = ''; } $allTickets = $this->ticketRepository->getAllBySearchCriteria( searchCriteria: $searchCriteria, sort: 'duedate', limit: $limit, includeCounts: false, offset: $offset); $statusLabels = $this->getAllStatusLabelsByUserId($userId); $tickets = []; foreach ($allTickets as $row) { // Only include todos that are not done if ( isset($statusLabels[$row['projectId']]) && isset($statusLabels[$row['projectId']][$row['status']]) && $statusLabels[$row['projectId']][$row['status']]['statusType'] != 'DONE' ) { if (isset($tickets[$row['projectId']])) { $tickets[$row['projectId']]['tickets'][] = $row; } else { $tickets[$row['projectId']] = [ 'labelName' => $row['clientName'].' / '.$row['projectName'], 'tickets' => [$row], 'groupValue' => $row['projectId'], ]; } } } return $tickets; } /** * @api */ #[RequiresPermission(TicketsPermissions::VIEW, projectIdParam: 'projectId')] public function getOpenUserTicketsByPriority($userId, $projectId = null, bool $includeMilestones = false, ?int $limit = null, ?int $offset = null, ?string $group = null): array { $searchCriteria = $this->prepareTicketSearchArray(['currentProject' => $projectId, 'users' => $userId, 'status' => '', 'sprint' => '']); if ($includeMilestones) { $searchCriteria['excludeType'] = ''; } $allTickets = $this->ticketRepository->getAllBySearchCriteria( searchCriteria: $searchCriteria, sort: 'priority', limit: $limit, includeCounts: false, offset: $offset); $statusLabels = $this->getAllStatusLabelsByUserId($userId); $tickets = []; foreach ($allTickets as $row) { // Only include todos that are not done if ( isset($statusLabels[$row['projectId']]) && isset($statusLabels[$row['projectId']][$row['status']]) && $statusLabels[$row['projectId']][$row['status']]['statusType'] != 'DONE' ) { if (empty($row['priority'])) { $row['priority'] = 999; $label = 'Unset'; } else { $label = $this->ticketRepository->priority[$row['priority']]; } if (isset($tickets[$row['priority']])) { $tickets[$row['priority']]['tickets'][] = $row; } else { // If the priority is not set, the label for priority not defined is used. if (empty($this->ticketRepository->priority[$row['priority']])) { $label = $this->language->__('label.priority_not_defined'); } $tickets[$row['priority']] = [ 'labelName' => $label, 'tickets' => [$row], 'groupValue' => $row['priority'], ]; } } } // Sort by group keys which are priority integers ksort($tickets); return $tickets; } /** * @api */ #[RequiresPermission(TicketsPermissions::VIEW, projectIdParam: 'projectId')] public function getOpenUserTicketsBySprint($userId, $projectId = null, bool $includeMilestones = false, ?int $limit = null, ?int $offset = null, ?string $group = null): array { $searchCriteria = $this->prepareTicketSearchArray(['currentProject' => $projectId, 'users' => $userId, 'status' => '', 'sprint' => '']); if ($includeMilestones) { $searchCriteria['excludeType'] = ''; } $allTickets = $this->ticketRepository->getAllBySearchCriteria( searchCriteria: $searchCriteria, sort: 'duedate', limit: $limit, includeCounts: false, offset: $offset); $statusLabels = $this->getAllStatusLabelsByUserId($userId); $tickets = []; foreach ($allTickets as $row) { $sprint = $row['sprint'] ?? 'backlog'; $sprintName = empty($row['sprintName']) ? $this->language->__('label.not_assigned_to_sprint') : $row['sprintName']; // Only include todos that are not done if ( isset($statusLabels[$row['projectId'] ?? '']) && isset($statusLabels[$row['projectId']][$row['status']]) && $statusLabels[$row['projectId']][$row['status']]['statusType'] != 'DONE' ) { if (isset($tickets[$sprint])) { $tickets[$sprint]['tickets'][] = $row; } else { $tickets[$sprint] = [ 'labelName' => $row['projectName'].' / '.$sprintName, 'tickets' => [$row], 'groupValue' => $row['sprint'].'-'.$row['projectId'], ]; } } } return $tickets; } /** * Retrieves all milestones based on the provided search criteria and sort option. * * @param array $searchCriteria Search parameters. Must be scoped to a project — either a single * 'currentProject' id (> 0) or a non-empty comma-separated 'projects' * set (used by program/cross-project boards). * @param string $sortBy The sorting option for the milestones. Defaults to 'standard'. * @return array Milestones sorted hierarchically; an empty array when the criteria are not project-scoped. * * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getAllMilestones($searchCriteria, string $sortBy = 'standard'): array { // Proceed when scoped to a single project (currentProject) OR to a set of projects // (the multi-project `projects` filter used by program boards). The currentProject // access is null-coalesced so a projects-only criteria array doesn't warn. $isScoped = is_array($searchCriteria) && ((($searchCriteria['currentProject'] ?? 0) > 0) || ! empty($searchCriteria['projects'])); if ($isScoped) { $items = $this->ticketRepository->getAllMilestones($searchCriteria, $sortBy) ?: []; return $this->sortItemsHierarchically($items); } return []; } private function buildTicketTree(array $elements, $parentId = 0) { $branch = []; foreach ($elements as $element) { $elementParentId = null; if ($element->type === 'milestone') { $elementParentId = $element->milestoneid; } elseif ($element->dependingTicketId > 0) { $elementParentId = $element->dependingTicketId; } elseif ($element->milestoneid > 0) { $elementParentId = $element->milestoneid; } if (is_null($elementParentId)) { $elementParentId = 0; } if ($elementParentId === $parentId) { $children = $this->buildTicketTree($elements, $element->id); if ($children) { usort($children, function ($a, $b) { if ($a->sortIndex > 0 && $b->sortIndex > 0) { return $a->sortIndex > $b->sortIndex ? 1 : -1; } // Otherwise compare dates if (dtHelper()->isValidDateString($a->editFrom) && dtHelper()->isValidDateString($b->editFrom)) { if (dtHelper()->parseDbDateTime($a->editFrom) > dtHelper()->parseDbDateTime($b->editFrom)) { return 1; } elseif (dtHelper()->parseDbDateTime($a->editFrom) < dtHelper()->parseDbDateTime($b->editFrom)) { return -1; } } return 0; }); $element->children = $children; } $branch[] = $element; } } return $branch; } private function flattenTree($items, &$r) { foreach ($items as $item) { $c = isset($item->children) ? $item->children : null; unset($item->children); $r[] = $item; if ($c) { $this->flattenTree($c, $r); } } } private function sortItemsHierarchically($items): array { $tree = []; $lookup = []; $tree = $this->buildTicketTree($items); $flattened = []; if (is_array($tree)) { $this->flattenTree($tree, $flattened); $final = $flattened; $sortKey = 0; foreach ($flattened as &$item) { $sortKey++; $item->sortIndex = $sortKey; } return $flattened; } return []; } private function sortTicketsWithinMilestone($tickets): array { usort($tickets, function ($a, $b) { // First priority: Dependencies if ($a->dependingTicketId == $b->id) { return 1; } if ($b->dependingTicketId == $a->id) { return -1; } // Second priority: sortIndex if ($a->sortIndex !== '' && $b->sortIndex !== '') { if ($a->sortIndex != $b->sortIndex) { return $a->sortIndex - $b->sortIndex; } } // Third priority: editFrom date if ($a->editFrom && $b->editFrom) { return strtotime($a->editFrom) - strtotime($b->editFrom); } return $a->id - $b->id; }); return $tickets; } /** * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getAllMilestonesOverview(bool $includeArchived = false, string $sortBy = 'duedate', bool $includeTasks = false, int $clientId = 0, array $searchCriteria = []): false|array { $searchParams = ['sprint' => '', 'type' => 'milestone', 'clients' => $clientId]; // Apply search criteria if provided if (! empty($searchCriteria)) { // Map search criteria to repository parameters if (isset($searchCriteria['status']) && $searchCriteria['status'] !== '') { $searchParams['status'] = $searchCriteria['status']; } if (isset($searchCriteria['users']) && $searchCriteria['users'] !== '') { $searchParams['users'] = $searchCriteria['users']; } if (isset($searchCriteria['milestone']) && $searchCriteria['milestone'] !== '') { $searchParams['milestone'] = $searchCriteria['milestone']; } if (isset($searchCriteria['term']) && $searchCriteria['term'] !== '') { $searchParams['term'] = $searchCriteria['term']; } if (isset($searchCriteria['priority']) && $searchCriteria['priority'] !== '') { $searchParams['priority'] = $searchCriteria['priority']; } if (isset($searchCriteria['currentProject']) && $searchCriteria['currentProject'] !== '') { $searchParams['currentProject'] = $searchCriteria['currentProject']; } } $allProjectMilestones = $this->ticketRepository->getAllMilestones($searchParams); return $allProjectMilestones; } /** * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getAllMilestonesByUserProjects($userId): array { $milestones = []; $userProjects = $this->projectService->getProjectsAssignedToUser($userId); if ($userProjects) { foreach ($userProjects as $project) { $allProjectMilestones = $this->getAllMilestones(['sprint' => '', 'type' => 'milestone', 'currentProject' => $project['id']]); $milestones[$project['id']] = $allProjectMilestones; } } if (session()->exists('currentProject')) { $allProjectMilestones = $this->getAllMilestones(['sprint' => '', 'type' => 'milestone', 'currentProject' => session('currentProject')]); $milestones[session('currentProject')] = $allProjectMilestones; } // There is a non zero chance that a user has tickets assigned to them without a project assignment. // Checking user assigned tickets to see if there are missing projects. $allTickets = $this->ticketRepository->getAllBySearchCriteria(['currentProject' => '', 'users' => $userId, 'status' => 'not_done', 'sprint' => ''], 'duedate'); foreach ($allTickets as $row) { if (! isset($milestones[$row['projectId']])) { $allProjectMilestones = $this->getAllMilestones(['sprint' => '', 'type' => 'milestone', 'currentProject' => session('currentProject')]); $milestones[$row['projectId']] = $allProjectMilestones; } } return $milestones; } /** * Calculate the progress of a milestone based on the tickets associated with it. * * @param int|string $milestoneId ID of the milestone. * @return float The progress of the milestone as a percentage. * * @throws EntryNotFoundException If the milestone with the given ID is not found. * * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getMilestoneProgress(int|string $milestoneId): float { if (is_numeric($milestoneId)) { $milestoneId = (int) $milestoneId; } $milestone = $this->getTicket($milestoneId); if (! $milestone) { throw new EntryNotFoundException("Can't find milestone"); } $prepareSearchParams = $this->prepareTicketSearchArray(['milestone' => $milestoneId, 'currentProject' => $milestone->projectId, 'currentSprint' => '']); $tickets = $this->ticketRepository->getAllBySearchCriteria($prepareSearchParams); $statusLabels = $this->getStatusLabels($milestone->projectId); $defaultEffort = 3; $defaultPriority = 3; // low number high priority high priority 1-5 low priority // We want to take priority into consideration but not make it the main driver. $priorityFactor = [ 1 => 2, 2 => 1.75, 3 => 1.5, 4 => 1.25, 5 => 1, ]; $totalScore = 0; $doneScore = 0; $inProgressScore = 0; foreach ($tickets as $ticket) { $effort = empty($ticket['storypoints']) ? $defaultEffort : $ticket['storypoints']; $priority = empty($ticket['priority']) ? $defaultPriority : $ticket['priority']; $ticketScore = $effort * ($priorityFactor[$priority] ?? 1); $totalScore += $ticketScore; if ( isset($statusLabels[$ticket['status']]) && $statusLabels[$ticket['status']]['statusType'] == 'DONE' ) { $doneScore += $ticketScore; continue; } if ( isset($statusLabels[$ticket['status']]) && $statusLabels[$ticket['status']]['statusType'] == 'INPROGRESS' ) { $inProgressScore += $ticketScore; } } if ($totalScore == 0) { return (float) 0; } $percentDone = $doneScore / $totalScore * 100; return (float) $percentDone; } public function getBulkMilestoneProgress(array $milestones) { if (empty($milestones)) { return $milestones; } foreach ($milestones as &$milestone) { if ($milestone->type == 'milestone') { $milestoneProgress = $this->getMilestoneProgress($milestone->id); $milestone->percentDone = $milestoneProgress; // Handle associated tickets if (isset($milestone->tickets)) { $milestone->tickets = $this->sortTicketsWithinMilestone($milestone->tickets); } } } return $milestones; } public function getRecentlyCompletedTicketsByUser(int $userId, ?int $projectId = null): array { // Get status labels $statusLabelsByProject = []; if ($projectId === null) { $userProjects = $this->projectService->getProjectsAssignedToUser($userId); if ($userProjects) { foreach ($userProjects as $project) { $statusLabelsByProject[$project['id']] = $this->ticketRepository->getStateLabels($project['id']); } } } else { $statusLabelsByProject[$projectId] = $this->ticketRepository->getStateLabels($projectId); } // Get tickets recently set to done (history table) $searchCriteria = $this->prepareTicketSearchArray(['currentProject' => '', 'users' => $userId, 'status' => 'done', 'sprint' => '', 'limit' => null]); $myCompletedTasks = $this->getAll($searchCriteria); $dateTime = new DateTime; $dateTime->modify('-1 week'); $doneTasks = []; foreach ($myCompletedTasks as $ticket) { $history = $this->ticketHistoryRepo->getRecentTicketHistory($dateTime, $ticket['id']); foreach ($history as $activity) { if ( $activity['changeType'] == 'status' && isset($statusLabelsByProject[$ticket['projectId']][$activity['changeValue']]) && $statusLabelsByProject[$ticket['projectId']][$activity['changeValue']]['statusType'] == 'DONE' ) { $doneTasks[] = $ticket; } } } return $doneTasks; } public function goalsRelatedToWork(int $userId, $projectId = null) { $statusLabelsByProject = []; if ($projectId === null) { $userProjects = $this->projectService->getProjectsAssignedToUser($userId); if ($userProjects) { foreach ($userProjects as $project) { $statusLabelsByProject[$project['id']] = $this->ticketRepository->getStateLabels($project['id']); } } } else { $statusLabelsByProject[$projectId] = $this->ticketRepository->getStateLabels($projectId); } // Get tickets recently set to done (history table) $searchCriteria = $this->prepareTicketSearchArray(['currentProject' => '', 'users' => $userId, 'status' => 'not_done', 'sprint' => '', 'limit' => null]); $myTask = $this->getAll($searchCriteria); $contributedToGoal = []; foreach ($myTask as $task) { if ($task['milestoneid'] !== '' && $task['milestoneid'] > 0) { $goals = $this->goalcanvasService->getGoalsByMilestone($task['milestoneid']); foreach ($goals as $goal) { if (! isset($contributedToGoal[$goal['id']])) { $contributedToGoal[$goal['id']] = $goal; } } } } return $contributedToGoal; } /** * @api */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getAllSubtasks(int $ticketId): false|array { // TODO: Refactor to be recursive return $this->ticketRepository->getAllSubtasks($ticketId); } /** * Adds a new ticket quickly based on the provided parameters. * * @param array $params An associative array of ticket details which may include: * - headline (string) : The title of the ticket (required). * - description (string) : The description of the task. * - projectId (int) : The ID of the project to which the ticket belongs. * - editorId (int) : The ID of the user editing/creating the ticket. * - userId (int) : The ID of the user assigned to the ticket. * - dateToFinish (string) : The due date for completion of the ticket. In user date format or ISO8601 * - status (int) : The status of the ticket (default: 3). * - sprint (int) : The sprint associated with the ticket. * - editFrom (string) : Start time for the edit period. In user date format or ISO8601 * - editTo (string) : End time for the edit period. In user date format or ISO8601 * - milestone (int) : The ID of the associated milestone. * @return array|bool Returns an array with ticket details if successful or false on failure. * If 'headline' is missing in $params or ticket creation fails, an error array will be returned with a status and message. * * @api */ #[RequiresPermission(TicketsPermissions::CREATE, entityScoped: true)] public function quickAddTicket($params): array|bool|int { $projectId = $params['projectId'] ?? session('currentProject'); $this->authorize(TicketsPermissions::CREATE, $projectId !== null ? (int) $projectId : null); // Resolve the default status from the PROJECT's status config // rather than hardcoding `3`. The hardcoded `3` was the "New" // status for the default Leantime install, but custom projects // can have status `3` mean "Done", "Blocked", or anything else, // and we don't want to silently create new tasks in those // statuses. Fall back to `3` only if the project has no // NEW-statusType status configured (which would itself be a // misconfiguration but shouldn't break task creation). $defaultStatus = 3; if ($projectId) { $statusLabels = $this->ticketRepository->getStateLabels((int) $projectId); if (is_array($statusLabels)) { foreach ($statusLabels as $statusId => $config) { if (($config['statusType'] ?? '') === 'NEW') { $defaultStatus = (int) $statusId; break; } } } } $values = [ 'headline' => $params['headline'], 'type' => $params['type'] ?? 'task', 'description' => $params['description'] ?? '', 'projectId' => $projectId, 'editorId' => $params['editorId'] ?? session('userdata.id'), 'userId' => session('userdata.id') ?? $params['userId'] ?? null, 'date' => date('Y-m-d H:i:s'), 'dateToFinish' => isset($params['dateToFinish']) ? strip_tags($params['dateToFinish']) : '', 'status' => isset($params['status']) ? (int) $params['status'] : $defaultStatus, 'storypoints' => isset($params['storypoints']) ? (int) $params['storypoints'] : '', 'hourRemaining' => '', 'planHours' => isset($params['planHours']) ? (int) $params['planHours'] : '', 'sprint' => isset($params['sprint']) ? (int) $params['sprint'] : '', 'acceptanceCriteria' => '', 'priority' => isset($params['priority']) ? (int) $params['priority'] : '', 'tags' => '', 'editFrom' => $params['editFrom'] ?? '', 'editTo' => $params['editTo'] ?? '', 'milestoneid' => isset($params['milestone']) ? (int) $params['milestone'] : '', 'dependingTicketId' => isset($params['dependingTicketId']) ? (int) $params['dependingTicketId'] : '', 'sortIndex' => $params['sortIndex'] ?? '', 'collaborators' => $params['collaborators'] ?? [], ]; if ($values['headline'] == '') { return ['status' => 'error', 'message' => 'Headline Missing']; } $values = $this->prepareTicketDates($values); $result = $this->ticketRepository->addTicket($values); TicketCreated::dispatch( ticketId: is_int($result) && $result > 0 ? $result : null, legacyHook: __FUNCTION__ ); if ($result > 0) { $values['id'] = $result; $actual_link = BASE_URL.'/dashboard/home#/tickets/showTicket/'.$result; $message = sprintf($this->language->__('email_notifications.new_todo_message'), session('userdata.name'), strip_tags($params['headline'])); $subject = $this->language->__('email_notifications.new_todo_subject'); $notification = new NotificationModel; $notification->url = [ 'url' => $actual_link, 'text' => $this->language->__('email_notifications.new_todo_cta'), ]; $notification->entity = $values; $notification->module = 'tickets'; $notification->action = 'created'; $notification->projectId = $values['projectId'] ?? session('currentProject') ?? -1; $notification->subject = $subject; $notification->authorId = session('userdata.id') ?? -1; $notification->message = $message; $this->projectService->notifyProjectUsers($notification); return $result; } return false; } /** * Adds a milestone quickly with the given parameters. * * @param array $params An associative array of milestone details, which includes: * - 'headline': string, The title or headline of the milestone. * - 'projectId': int|null, The ID of the project associated with the milestone (optional). * - 'editorId': int|null, The user ID of the editor creating the milestone (optional). * - 'userId': int|null, The user ID associated with the milestone (optional). * - 'dependentMilestone': int|null, The ID of a milestone it depends on (optional). * - 'tags': string|null, Tags related to the milestone (optional). * - 'editFrom': string|null, Start time of editing (optional). * - 'editTo': string|null, End time of editing (optional). * @return array|bool|int Returns the ticket creation result. If an error occurs, an array with 'status' and 'message' keys is returned. * * @api */ #[RequiresPermission(TicketsPermissions::CREATE, entityScoped: true)] public function quickAddMilestone(array $params): array|bool|int { $projectId = $params['projectId'] ?? session('currentProject'); $this->authorize(TicketsPermissions::CREATE, $projectId !== null ? (int) $projectId : null); $values = [ 'headline' => $params['headline'], 'type' => 'milestone', 'description' => '', 'projectId' => $projectId, 'editorId' => $params['editorId'] ?? session('userdata.id'), 'userId' => session('userdata.id') ?? $params['userId'] ?? null, 'date' => dtHelper()->userNow()->formatDateTimeForDb(), 'dateToFinish' => '', 'status' => 3, 'storypoints' => '', 'hourRemaining' => '', 'planHours' => '', 'sprint' => '', 'priority' => 3, 'dependingTicketId' => '', 'milestoneid' => $params['dependentMilestone'] ?? '', 'acceptanceCriteria' => '', 'outcomeImpact' => $params['outcomeImpact'] ?? '', 'tags' => $params['tags'] ?? '', 'editFrom' => $params['editFrom'] ?? '', 'editTo' => $params['editTo'] ?? '', ]; $values = $this->prepareTicketDates($values); if ($values['headline'] == '') { $error = ['status' => 'error', 'message' => 'Headline Missing']; return $error; } MilestoneCreated::dispatch(legacyHook: __FUNCTION__); // $params is an array of field names. Exclude id return $this->ticketRepository->addTicket($values); } /** * Adds a ticket to the system. * * @param array $values An array of ticket data. * - id (optional): The ID of the ticket. * - headline (optional): The headline of the ticket. * - type (optional): The type of the ticket. Default is "task". * - description (optional): The description of the ticket. * - projectId (optional): The ID of the project the ticket belongs to. Default is the current project. * - editorId (optional): The ID of the editor of the ticket. * - userId: The ID of the user creating the ticket. * - date: The date when the ticket is created. * - dateToFinish (optional): The date to finish the ticket. * - timeToFinish (optional): The time to finish the ticket. * - status (optional): The status of the ticket. Default is 3. * - planHours (optional): The planned hours for the ticket. * - tags (optional): The tags associated with the ticket. * - sprint (optional): The sprint the ticket belongs to. * - storypoints (optional): The story points assigned to the ticket. * - hourRemaining (optional): The remaining hours for the ticket. * - priority (optional): The priority of the ticket. * - acceptanceCriteria (optional): The acceptance criteria of the ticket. * - editFrom (optional): The edit from date of the ticket. * - timeFrom (optional): The edit from time of the ticket. * - editTo (optional): The edit to date of the ticket. * - timeTo (optional): The edit to time of the ticket. * - dependingTicketId (optional): The ID of the depending ticket. * - milestoneid (optional): The ID of the milestone the ticket belongs to. * @return array|int|bool If the ticket is successfully added, returns the ID of the ticket. * If the user does not have access to the project, returns an error message and type array. * If the headline is missing, returns an error message and type array. * * @api */ #[RequiresPermission(TicketsPermissions::CREATE, entityScoped: true)] public function addTicket($values): array|int|bool { $values = [ 'id' => '', 'headline' => $values['headline'] ?? '', 'type' => $values['type'] ?? 'task', 'description' => $values['description'] ?? '', 'projectId' => $values['projectId'] ?? session('currentProject'), 'editorId' => $values['editorId'] ?? '', 'userId' => session('userdata.id'), 'date' => gmdate('Y-m-d H:i:s'), 'dateToFinish' => $values['dateToFinish'] ?? '', 'timeToFinish' => $values['timeToFinish'] ?? '', 'status' => $values['status'] ?? 3, 'planHours' => $values['planHours'] ?? '', 'tags' => $values['tags'] ?? '', 'sprint' => $values['sprint'] ?? '', 'storypoints' => $values['storypoints'] ?? '', 'hourRemaining' => $values['hourRemaining'] ?? '', 'priority' => $values['priority'] ?? '', 'acceptanceCriteria' => $values['acceptanceCriteria'] ?? '', 'outcomeImpact' => $values['outcomeImpact'] ?? '', 'editFrom' => $values['editFrom'] ?? '', 'timeFrom' => $values['timeFrom'] ?? '', 'editTo' => $values['editTo'] ?? '', 'timeTo' => $values['timeTo'] ?? '', 'dependingTicketId' => $values['dependingTicketId'] ?? '', 'milestoneid' => $values['milestoneid'] ?? '', 'collaborators' => $values['collaborators'] ?? [], ]; // Editor+ role in the target project AND access to it (engine combines capability + // project membership). Replaces the previous access-only check, which let any // assigned role create via RPC. $this->authorize(TicketsPermissions::CREATE, (int) $values['projectId']); if ($values['headline'] === '') { return ['msg' => 'notifications.ticket_save_error_no_headline', 'type' => 'error']; } else { $values = $this->prepareTicketDates($values); // Update Ticket $addTicketResponse = $this->ticketRepository->addTicket($values); TicketCreated::dispatch( ticketId: is_int($addTicketResponse) && $addTicketResponse > 0 ? $addTicketResponse : null, legacyHook: __FUNCTION__ ); if ($addTicketResponse !== false) { $values['id'] = $addTicketResponse; $subject = sprintf($this->language->__('email_notifications.new_todo_subject'), $addTicketResponse, strip_tags($values['headline'])); $actual_link = BASE_URL.'/dashboard/home#/tickets/showTicket/'.$addTicketResponse; $message = sprintf($this->language->__('email_notifications.new_todo_message'), session('userdata.name'), strip_tags($values['headline'])); $notification = new NotificationModel; $notification->url = [ 'url' => $actual_link, 'text' => $this->language->__('email_notifications.new_todo_cta'), ]; $notification->entity = $values; $notification->module = 'tickets'; $notification->action = 'created'; $notification->projectId = $values['projectId'] ?? session('currentProject') ?? -1; $notification->subject = $subject; $notification->authorId = session('userdata.id') ?? -1; $notification->message = $message; $this->projectService->notifyProjectUsers($notification); return $addTicketResponse; } } return false; } // Update /** * Updates the details of an existing ticket based on the provided parameters. * * @param array $values An associative array containing the ticket details to be updated, which includes: * - 'id': int, The ID of the ticket to be updated. * - 'headline': string|null, The title or headline of the ticket (optional). * - 'type': string|null, The type or category of the ticket (optional). * - 'description': string|null, The description of the ticket (optional). * - 'projectId': int|null, The ID of the project associated with the ticket (optional). * - 'editorId': int|null, The user ID of the editor updating the ticket (optional). * - 'dateToFinish': string|null, The intended completion date for the ticket (optional). * - 'timeToFinish': string|null, The intended completion time for the ticket (optional). * - 'status': int|null, The current status of the ticket (optional). * - 'planHours': string|null, The planned hours for the ticket (optional). * - 'tags': string|null, Tags associated with the ticket (optional). * - 'sprint': string|null, The sprint associated with the ticket (optional). * - 'storypoints': string|null, The story points for the ticket (optional). * - 'hourRemaining': string|null, The remaining hours for the ticket (optional). * - 'priority': int|null, The priority level of the ticket (optional). * - 'acceptanceCriteria': string|null, Acceptance criteria for completing the ticket (optional). * - 'editFrom': string|null, Start date of ticket editing (optional). * - 'timeFrom': string|null, Start time of ticket editing (optional). * - 'editTo': string|null, End date for ticket editing (optional). * - 'timeTo': string|null, End time for ticket editing (optional). * - 'dependingTicketId': int|null, A ticket ID this ticket depends on (optional). * - 'milestoneid': int|null, The ID of the milestone associated with this ticket (optional). * @return array|bool Returns true if the ticket is successfully updated. * If an error occurs, an array with keys 'msg' and 'type' is returned. Returns false if the update operation fails. * * @api */ #[RequiresPermission(TicketsPermissions::EDIT, entityScoped: true)] public function updateTicket($values): array|bool { // Server-side authorization. Editing is gated to editor+ in the UI, but the // Kanban/Table modal path posted straight to updateTicket without enforcing it, // letting commenter/reader roles edit tickets via a direct request (#3376). // // Authorize against the ticket's CURRENT project, not the session project. // getTicket() returns false unless the user is assigned to the ticket's // project, and the editor check is then evaluated against THAT project's // role. Leantime roles are project-scoped, so an editor in project A who is // only a commenter in project B must not be able to edit B's ticket by // keeping the session on A and posting B's ticket id. (#3376 + review) $currentTicket = $this->getTicket($values['id']); if (! $currentTicket) { return ['msg' => 'notifications.ticket_save_error_no_access', 'type' => 'error']; } if (! $this->userIsAtLeastForProject(Roles::$editor, (int) $currentTicket->projectId)) { return ['msg' => 'notifications.ticket_save_error_no_access', 'type' => 'error']; } if (! isset($values['headline'])) { $values['headline'] = $currentTicket->headline; } // Only touch the outcome narrative when the caller sends it — most edit forms don't // carry the field, and defaulting it to '' would wipe a saved milestone outcome // (the repository skips the column when the key is absent). $hasOutcomeImpact = array_key_exists('outcomeImpact', $values); $outcomeImpact = $values['outcomeImpact'] ?? null; $values = [ 'id' => $values['id'], 'headline' => $values['headline'] ?? '', 'type' => $values['type'] ?? '', 'description' => $values['description'] ?? '', 'projectId' => $values['projectId'] ?? session('currentProject'), 'editorId' => $values['editorId'] ?? '', 'date' => dtHelper()->userNow()->formatDateTimeForDb(), 'dateToFinish' => $values['dateToFinish'] ?? '', 'timeToFinish' => $values['timeToFinish'] ?? '', 'status' => $values['status'] ?? '', 'planHours' => $values['planHours'] ?? '', 'tags' => $values['tags'] ?? '', 'sprint' => $values['sprint'] ?? '', 'storypoints' => $values['storypoints'] ?? '', 'hourRemaining' => $values['hourRemaining'] ?? '', 'priority' => $values['priority'] ?? '', 'acceptanceCriteria' => $values['acceptanceCriteria'] ?? '', 'editFrom' => $values['editFrom'] ?? '', 'timeFrom' => $values['timeFrom'] ?? '', 'editTo' => $values['editTo'] ?? '', 'timeTo' => $values['timeTo'] ?? '', 'dependingTicketId' => $values['dependingTicketId'] ?? '', 'milestoneid' => $values['milestoneid'] ?? '', 'collaborators' => $values['collaborators'] ?? [], ]; if ($hasOutcomeImpact) { $values['outcomeImpact'] = $outcomeImpact; } if ($values['projectId'] === null || $values['projectId'] === '' || $values['projectId'] === false) { return ['msg' => 'project id is not set', 'type' => 'error']; } if (! $this->projectService->isUserAssignedToProject(session('userdata.id'), $values['projectId'])) { return ['msg' => 'notifications.ticket_save_error_no_access', 'type' => 'error']; } $values = $this->prepareTicketDates($values); // Update Ticket if ($this->ticketRepository->updateTicket($values, $values['id']) === true) { $subject = sprintf($this->language->__('email_notifications.todo_update_subject'), $values['id'], strip_tags($values['headline'])); $actual_link = BASE_URL.'/dashboard/home#/tickets/showTicket/'.$values['id']; $message = sprintf($this->language->__('email_notifications.todo_update_message'), session('userdata.name'), strip_tags($values['headline'])); $notification = new NotificationModel; $notification->url = [ 'url' => $actual_link, 'text' => $this->language->__('email_notifications.todo_update_cta'), ]; $notification->entity = $values; $notification->module = 'tickets'; $notification->action = 'updated'; $notification->projectId = $values['projectId'] ?? session('currentProject') ?? -1; $notification->subject = $subject; $notification->authorId = session('userdata.id') ?? -1; $notification->message = $message; $this->projectService->notifyProjectUsers($notification); TicketUpdated::dispatch(ticketId: (int) $values['id'], legacyHook: __FUNCTION__); return true; } return false; } /** * Adds a new ticket and optionally associates tags with it. * * @param int $id The unique identifier of the task to be updated. * @param array $params An associative array containing the updated task details. * The array should not include: * - 'id': The ID of the task (it is excluded automatically). * - 'act': Internal action parameter (it is excluded automatically). * Additional fields may include: * - 'status': The updated status of the task (optional). * - 'headline': string|null, The title or headline of the ticket (optional). * - 'type': string|null, The type or category of the ticket (optional). * - 'description': string|null, The description of the ticket (optional). * - 'projectId': int|null, The ID of the project associated with the ticket (optional). * - 'editorId': int|null, The user ID of the editor updating the ticket (optional). * - 'dateToFinish': string|null, The intended completion date for the ticket (optional). * - 'timeToFinish': string|null, The intended completion time for the ticket (optional). * - 'status': int|null, The current status of the ticket (optional). * - 'planHours': string|null, The planned hours for the ticket (optional). * - 'tags': string|null, Tags associated with the ticket (optional). * - 'sprint': string|null, The sprint associated with the ticket (optional). * - 'storypoints': string|null, The story points for the ticket (optional). * - 'hourRemaining': string|null, The remaining hours for the ticket (optional). * - 'priority': int|null, The priority level of the ticket (optional). * - 'acceptanceCriteria': string|null, Acceptance criteria for completing the ticket (optional). * - 'editFrom': string|null, Start date of ticket editing (optional). * - 'timeFrom': string|null, Start time of ticket editing (optional). * - 'editTo': string|null, End date for ticket editing (optional). * - 'timeTo': string|null, End time for ticket editing (optional). * - 'dependingTicketId': int|null, A ticket ID this ticket depends on (optional). * - 'milestoneid': int|null, The ID of the milestone associated with this ticket (optional). * @return bool Returns true if the task is successfully updated, otherwise false. * * @api */ /** * @api * * Convenience method for "mark this ticket done" without the client * needing to know the project's status ID for DONE. Looks up the * project's status config, finds the first status with statusType * === 'DONE', and patches the ticket's status to that ID. * * The mobile app calls this from its list-view quick-complete * checkbox; without it, mobile would have to preload every project's * status config just to mark a single task as done. * * Returns true on success, false if the ticket doesn't exist or the * project has no DONE-type status configured. */ /** * @api * * Inverse of getAllOpenUserTickets — returns the user's DONE tasks * (statusType === 'DONE' for the project). Used by mobile's * "Done" filter to show completed work. */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getAllDoneUserTickets(?int $userId = null, ?int $project = null): array { $tickets = $this->ticketRepository->simpleTicketQuery($userId, $project); $ticketArray = []; if (is_array($tickets)) { $projectStatusLabels = []; foreach ($tickets as $ticket) { if ($ticket['type'] !== 'milestone') { if (! isset($projectStatusLabels[$ticket['projectId']])) { $projectStatusLabels[$ticket['projectId']] = $this->ticketRepository->getStateLabels($ticket['projectId']); } $statusConfig = $projectStatusLabels[$ticket['projectId']][$ticket['status']] ?? null; if ($statusConfig && ($statusConfig['statusType'] ?? '') === 'DONE') { $ticket['statusLabel'] = $statusConfig['name']; $ticket['statusClass'] = $statusConfig['class'] ?? ''; $ticket['statusType'] = $statusConfig['statusType'] ?? ''; $ticketArray[] = $ticket; } } } } return $ticketArray; } /** * @api * * Returns the user's tasks that were marked DONE on a specific date * (default today), each annotated with `dateClosed` (the completion * timestamp). Powers the mobile "Done today" reflection — an accurate, * complete mirror of what actually got finished, including unplanned work. * * "Closed on date" means the ticket is currently DONE *and* its status * was changed to that DONE status on the given date (per zp_tickethistory). * So a task finished on an earlier day is excluded, and a task reopened * then re-completed the same day is included once. Built on the general * getStatusChangeEvents primitive, so throughput/burndown and strategy * reporting can reuse the same query. * * Thin wrapper over {@see getMyClosedTicketsForRange} with from == to. */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getMyClosedTicketsForDate(?int $userId = null, ?string $date = null): array { $date = $date ?: date('Y-m-d'); return $this->getMyClosedTicketsForRange($userId, $date, $date); } /** * @api * * Range form of {@see getMyClosedTicketsForDate}: the user's tasks marked * DONE anywhere within [$from, $to] (inclusive, dates 'Y-m-d'), each * annotated with `dateClosed` (the completion timestamp). Powers the mobile * Progress "Closed this week / this month" sections — completed arcs are * keyed on close-date within the period, independent of how recently the * project was otherwise touched, so finished work never disappears. * * Same "closed" definition as the single-date form: the ticket is currently * DONE and its status was changed to that DONE status within the range. A * ticket completed more than once in the range is included once, keyed to * its latest completion (events are newest-first). * * Bounds: an omitted $from or $to defaults to today; the range is then * normalized so the earlier date is the lower bound (a reversed range is * swapped rather than returning nothing). So passing only one bound yields * the span between that date and today, in date order. * * A non-admin may only read their OWN closures: a caller-supplied $userId * for someone else is forced back to the session user (IDOR guard). */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getMyClosedTicketsForRange(?int $userId = null, ?string $from = null, ?string $to = null): array { $sessionUser = (int) session('userdata.id'); $userId = $userId ?: $sessionUser; // IDOR guard: reading another user's closures requires admin. if ($userId !== $sessionUser && ! Auth::userIsAtLeast(Roles::$admin)) { $userId = $sessionUser; } if ($userId === 0) { return []; } // Resolve "today" once (in the USER's calendar, not the server's) so a run // across midnight can't disagree on bounds. $today = dtHelper()->userNow()->format('Y-m-d'); $from = $from ?: $today; $to = $to ?: $today; // Tolerate a reversed range rather than returning nothing. if ($from > $to) { [$from, $to] = [$to, $from]; } // Candidates: the user's currently-DONE tickets (statusType resolved). $doneTickets = $this->getAllDoneUserTickets($userId); if (empty($doneTickets)) { return []; } $byId = []; foreach ($doneTickets as $ticket) { $byId[$ticket['id']] = $ticket; } $events = $this->ticketRepository->getStatusChangeEvents(array_keys($byId), $from, $to); $closed = []; foreach ($events as $event) { $ticketId = (int) $event['ticketId']; $ticket = $byId[$ticketId] ?? null; if ($ticket === null || isset($closed[$ticketId])) { continue; } // Only count changes INTO the ticket's current (DONE) status — it // was actually marked done in the range, not merely touched. Events // are newest-first, so the first match keeps the latest completion. if ((string) $event['changeValue'] === (string) $ticket['status']) { $ticket['dateClosed'] = $event['dateModified']; $closed[$ticketId] = $ticket; } } return array_values($closed); } /** * @api * * Tickets the user COMMENTED on within [$from, $to] that they do NOT own * (they're not the ticket's editor) — i.e. work they supported by weighing * in on someone else's arc. Powers the mobile Progress "Supported" section * (presence counts as much as production). * * Access safety: commented ticket ids are constrained to the PROJECTS the * user can access (getProjectsUserHasAccessTo) — NOT to tickets they edit * or collaborate on. Commenting on a ticket rarely makes you its editor or * a collaborator, so scoping by those (as an earlier version did via * simpleTicketQuery) silently dropped most supported work — it collapsed to * "commented AND collaborator" and could return empty even when comments * exist. Project access is the correct, still-safe boundary: a historical * comment can't surface a ticket in a project the user no longer sees. The * ownership filter then drops tickets the user is the editor of — those are * "your work," not support. Defaults either bound to today. * * A non-admin may only read their OWN commented tickets: a caller-supplied * $userId for someone else is forced back to the session user (IDOR guard, * matching Projects::getProjectsUserHasAccessTo()). * * @return array> Raw ticket rows (id, headline, * projectName, editorId, status, …). * * Note: these rows do NOT carry the resolved statusLabel / statusClass / * statusType that the getAll*UserTickets methods attach. */ #[RequiresPermission(TicketsPermissions::VIEW)] public function getMyCommentedTicketsForRange(?int $userId = null, ?string $from = null, ?string $to = null): array { $sessionUser = (int) session('userdata.id'); $userId = $userId ?: $sessionUser; // IDOR guard: reading someone else's comment activity requires admin. if ($userId !== $sessionUser && ! Auth::userIsAtLeast(Roles::$admin)) { $userId = $sessionUser; } if ($userId === 0) { return []; } // Resolve "today" once (in the USER's calendar, not the server's) so a run // across midnight can't disagree on bounds. $today = dtHelper()->userNow()->format('Y-m-d'); $from = $from ?: $today; $to = $to ?: $today; if ($from > $to) { [$from, $to] = [$to, $from]; } $commentedIds = $this->ticketRepository->getTicketIdsCommentedByUser($userId, $from, $to); if (empty($commentedIds)) { return []; } // Scope to the projects the user can see — the correct access boundary // for "tickets I commented on" (you comment on others' work without // being its editor/collaborator, so filtering by those would drop it). $projects = $this->projectService->getProjectsUserHasAccessTo($userId); if (! is_array($projects) || empty($projects)) { return []; } $projectIds = array_values(array_filter(array_map( fn ($p) => (int) ($p['id'] ?? 0), $projects ))); if (empty($projectIds)) { return []; } $tickets = $this->ticketRepository->getTicketsByIdsWithinProjects($commentedIds, $projectIds); // "Supported", not "yours": drop tickets the user is the editor of. $out = []; foreach ($tickets as $ticket) { if ((string) ($ticket['editorId'] ?? '') === (string) $userId) { continue; } $out[] = $ticket; } return array_values($out); } /** * @api * * Companion to markTicketDone for un-completing. Resolves the * project's first NEW-statusType status and patches to it. Used by * mobile's "Done" filter — tap the checked checkbox to bring a task * back into the active list. */ #[RequiresPermission(TicketsPermissions::EDIT, entityScoped: true)] public function markTicketReopen(int $id): bool { $ticket = $this->ticketRepository->getTicket($id); if (! $ticket || empty($ticket->projectId)) { return false; } $this->authorize(TicketsPermissions::EDIT, (int) $ticket->projectId); $statusLabels = $this->ticketRepository->getStateLabels((int) $ticket->projectId); if (! is_array($statusLabels)) { return false; } $newStatusId = null; foreach ($statusLabels as $statusId => $config) { if (($config['statusType'] ?? '') === 'NEW') { $newStatusId = (int) $statusId; break; } } if ($newStatusId === null) { return false; } return $this->patch($id, ['status' => $newStatusId]); } /** * Mark a ticket done by resolving the project's first DONE-statusType status and patching to * it. Mobile's swipe-to-complete (the marquee gesture). Companion to {@see markTicketReopen}. * * Was unexposed (-32601) AND unauthorized — unlike its reopen companion it carried no @api, * no #[RequiresPermission], and no in-body authorize (patch()'s dispatch attribute does not * fire on this internal call). Now mirrors markTicketReopen exactly. * * @api */ #[RequiresPermission(TicketsPermissions::EDIT, entityScoped: true)] public function markTicketDone(int $id): bool { $ticket = $this->ticketRepository->getTicket($id); if (! $ticket || empty($ticket->projectId)) { return false; } $this->authorize(TicketsPermissions::EDIT, (int) $ticket->projectId); $statusLabels = $this->ticketRepository->getStateLabels((int) $ticket->projectId); if (! is_array($statusLabels)) { return false; } $doneStatusId = null; foreach ($statusLabels as $statusId => $config) { if (($config['statusType'] ?? '') === 'DONE') { $doneStatusId = (int) $statusId; break; } } if ($doneStatusId === null) { return false; } return $this->patch($id, ['status' => $doneStatusId]); } /** * Authorized JSON-RPC entry point for patching a single ticket field. * * Unlike the internal patch(), this enforces authorization because the * JSON-RPC endpoint has no controller-level role gate: the caller must be an * editor or above AND be assigned to the ticket's project (prevents * cross-project IDOR via a smuggled ticket id). * * @param int $id The ticket id to update * @param array $values The fields to update * @return bool True on success (false only if the underlying write fails) * * @throws AuthorizationException If the caller is not an editor, or is not assigned to the ticket's project * @throws NotFoundException If the ticket does not exist * * @api */ #[RequiresPermission(TicketsPermissions::EDIT, entityScoped: true)] public function patchTicket(int $id, array $values): bool { // getTicket() returns false when the user can't access the ticket's project. $ticket = $this->getTicket($id); if (! $ticket) { throw new NotFoundException('The task you tried to edit could not be found.'); } // Editor+ in the ticket's project (project-scoped role, not the session role) AND // access to it. Replaces the prior session-scoped userIsAtLeast + assignment checks. $this->authorize(TicketsPermissions::EDIT, (int) $ticket->projectId); return $this->patch($id, $values); } /** * Set a ticket's status from a semantic status type ("new" / "inprogress" / "done"). * * Used by the program cross-project kanban: columns are status types, but the value * written is always a real status key that exists in the ticket's OWN project, so a * drag on the program board can never leave the task with a status its project board * doesn't recognize. Authorization (edit in the ticket's project) is delegated to * patchTicket(). * * @api */ public function setTicketStatusByType(int $ticketId, string $statusType): bool { $ticket = $this->getTicket($ticketId); if (! $ticket) { throw new NotFoundException('The task you tried to edit could not be found.'); } $targetStatus = $this->resolveProjectStatusKeyForType( (int) $ticket->projectId, $statusType, $ticket->status === null ? null : (int) $ticket->status ); if ($targetStatus === null) { return false; } return $this->patchTicket($ticketId, ['status' => $targetStatus]); } /** * Resolve a status key, valid in $projectId, for a desired semantic status type. * * Preserves the current status when it already belongs to the requested type, so a * cross-project board move never collapses e.g. "Blocked" into plain "In Progress". * Otherwise prefers the seed canonical key for the type, falling back to the lowest * sortKey status of that type in the project. */ private function resolveProjectStatusKeyForType(int $projectId, string $statusType, ?int $currentStatus = null): ?int { $statusType = strtoupper($statusType); $labels = $this->ticketRepository->getStateLabels($projectId); // Already the right type → keep the existing key (only the column changed visually). if ($currentStatus !== null && isset($labels[$currentStatus]) && ($labels[$currentStatus]['statusType'] ?? '') === $statusType) { return $currentStatus; } // Prefer the seed canonical key for this type when present with the matching type. $canonicalByType = ['NEW' => 3, 'INPROGRESS' => 4, 'DONE' => 0]; $canonical = $canonicalByType[$statusType] ?? null; if ($canonical !== null && isset($labels[$canonical]) && ($labels[$canonical]['statusType'] ?? '') === $statusType) { return $canonical; } // Otherwise the lowest sortKey status of that type defined in the project. $candidates = []; foreach ($labels as $key => $status) { if (($status['statusType'] ?? '') === $statusType) { $candidates[$key] = $status['sortKey'] ?? PHP_INT_MAX; } } if ($candidates === []) { return null; } asort($candidates); return (int) array_key_first($candidates); } public function patch($id, $params): bool { if (! is_array($params)) { return false; } // Strip non-ticket fields that may leak in from the framework or form submissions unset( $params['id'], $params['act'], $params['request_parts'], $params['saveTicket'], $params['saveAndCloseTicket'], ); $ticket = $this->getTicket($id); if (! $ticket) { return false; } // Reassigning a ticket to a different project requires edit rights in the TARGET project, // not just the source (which the @api callers already authorized). Without this a user // could move/inject a ticket into a project they have no access to. if (isset($params['projectId']) && (int) $params['projectId'] !== (int) $ticket->projectId) { $this->authorize(TicketsPermissions::EDIT, (int) $params['projectId']); } // Handle collaborators separately since they live in the relationship table, not on zp_tickets $collaboratorsUpdated = false; if (array_key_exists('collaborators', $params)) { $collaborators = is_array($params['collaborators']) ? $params['collaborators'] : []; $this->ticketRepository->removeCollaborators($id); $this->ticketRepository->addCollaborators($id, $collaborators, session('userdata.id')); $collaboratorsUpdated = true; unset($params['collaborators']); } $params = $this->prepareTicketDates($params); $return = $this->ticketRepository->patchTicket($id, $params); if (! $return && ! $collaboratorsUpdated) { return false; } TicketUpdated::dispatch(ticketId: (int) $id, legacyHook: __FUNCTION__); // Todo: create events and move notification logic to notification module if (isset($params['status'])) { $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->action = 'status_changed'; $notification->projectId = $ticket->projectId ?? session('currentProject') ?? -1; $notification->subject = $subject; $notification->authorId = session('userdata.id'); $notification->message = $message; $this->projectService->notifyProjectUsers($notification); } return true; } /** * moveTicket - Moves a ticket from one project to another. Milestone children will be moved as well. * * @throws BindingResolutionException * * @api */ #[RequiresPermission(TicketsPermissions::EDIT, entityScoped: true)] public function moveTicket(int $id, int $projectId): bool { $ticket = $this->getTicket($id); if (! $ticket) { return false; } // Edit rights in BOTH the source project (above) and the TARGET project — otherwise a // user with access to project A could inject tickets into project B they can't touch. $this->authorize(TicketsPermissions::EDIT, (int) $ticket->projectId); $this->authorize(TicketsPermissions::EDIT, $projectId); if ($ticket->type == 'milestone') { $milestoneTickets = $this->getAll(['milestone' => $ticket->id]); foreach ($milestoneTickets as $childTicket) { $childMoved = $this->patch($childTicket['id'], [ 'projectId' => $projectId, 'sprint' => null, ]); if (! $childMoved) { return false; } } } return $this->patch($ticket->id, [ 'projectId' => $projectId, 'sprint' => null, 'dependingTicketId' => null, 'milestoneid' => null, ]); } /** * @return bool|string[] * * @api */ #[RequiresPermission(TicketsPermissions::EDIT, entityScoped: true)] public function quickUpdateMilestone($params): array|bool { if ($params['headline'] == '') { return ['status' => 'error', 'message' => 'Headline Missing']; } $milestoneId = (int) $params['id']; // Load via the service so the project-assignment gate applies; a milestone // in a project the user can't access returns false. (review) $existingMilestone = $this->getTicket($milestoneId); if (! $existingMilestone) { return ['status' => 'error', 'message' => 'You are not allowed to edit this milestone.']; } $currentProjectId = (int) $existingMilestone->projectId; // Editing a milestone is an edit op: require editor+ in the milestone's // OWN project, evaluated project-scoped rather than against the session // project's role. (review) if (! $this->userIsAtLeastForProject(Roles::$editor, $currentProjectId)) { return ['status' => 'error', 'message' => 'You are not allowed to edit this milestone.']; } // Honor the project chosen in the milestone dialog (#3294); the dialog // posts projectId via a