Files
Leantime/app/Domain/Tickets/Services/Tickets.php

5254 lines
212 KiB
PHP

<?php
namespace Leantime\Domain\Tickets\Services;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use DateTime;
use Illuminate\Container\EntryNotFoundException;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Domains\BaseService;
use Leantime\Core\Events\DispatchesEvents;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Core\Exceptions\NotFoundException;
use Leantime\Core\Language as LanguageCore;
use Leantime\Core\Support\DateTimeHelper;
use Leantime\Domain\Auth\Models\Roles;
use Leantime\Domain\Auth\Services\Auth;
use Leantime\Domain\Clients\Services\Clients as ClientService;
use Leantime\Domain\Comments\Services\Comments as CommentService;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas;
use Leantime\Domain\Notifications\Models\Notification as NotificationModel;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
use Leantime\Domain\Sprints\Services\Sprints as SprintService;
use Leantime\Domain\Tickets\Events\MilestoneCreated;
use Leantime\Domain\Tickets\Events\MilestoneDeleted;
use Leantime\Domain\Tickets\Events\MilestoneUpdated;
use Leantime\Domain\Tickets\Events\StatusLabelsUpdated;
use Leantime\Domain\Tickets\Events\TicketCreated;
use Leantime\Domain\Tickets\Events\TicketDeleted;
use Leantime\Domain\Tickets\Events\TicketListFilter;
use Leantime\Domain\Tickets\Events\TicketUpdated;
use Leantime\Domain\Tickets\Events\TodoWidgetTasksFilter;
use Leantime\Domain\Tickets\Models\BoardSummary;
use Leantime\Domain\Tickets\Models\Tickets as TicketModel;
use Leantime\Domain\Tickets\Permissions\TicketsPermissions;
use Leantime\Domain\Tickets\Repositories\TicketHistory;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
use Leantime\Domain\Timesheets\Repositories\Timesheets as TimesheetRepository;
use Leantime\Domain\Timesheets\Services\Timesheets as TimesheetService;
/**
* @api
*/
class Tickets extends BaseService
{
use DispatchesEvents;
/**
* Request-scoped memo for getAllStatusLabelsByUserId(), keyed by "userId|currentProject".
*
* @var array<string, array>
*/
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 = "<div class='profileImage'><img src='".BASE_URL.'/api/users?profileImage='.$ticket['editorId']."' /></div> ".$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 = "<i class='fa ".($icon[strtolower($ticket['type'])] ?? '')."'></i>".$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<int, array<string, mixed>> $tickets
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>> 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 <select>. Fall back to the milestone's current
// project when callers (e.g. inline kanban edits) don't supply one.
$targetProjectId = (int) ($params['projectId'] ?? $currentProjectId);
if ($targetProjectId !== $currentProjectId) {
// The projectId is caller-supplied: require editor+ in the target
// project too, so a milestone can't be moved into a project where the
// user lacks edit rights. (#3294 review / IDOR)
if (! $this->userIsAtLeastForProject(Roles::$editor, $targetProjectId)) {
return ['status' => 'error', 'message' => 'You are not allowed to move this milestone to that project.'];
}
// Moving a milestone must take its tasks with it, otherwise they're
// left orphaned referencing a milestone in another project. moveTicket()
// already moves the milestone's children and the milestone row. (#3294 review)
if ($this->moveTicket($milestoneId, $targetProjectId) === false) {
return ['status' => 'error', 'message' => 'Could not move milestone to the new project.'];
}
}
$values = [
'headline' => $params['headline'],
'type' => 'milestone',
'description' => '',
'projectId' => $targetProjectId,
'editorId' => $params['editorId'],
'userId' => session('userdata.id'),
'date' => dtHelper()->userNow()->formatDateTimeForDb(),
'dateToFinish' => '',
'status' => $params['status'],
'storypoints' => '',
'hourRemaining' => '',
'planHours' => '',
'sprint' => '',
'acceptanceCriteria' => '',
'priority' => 3,
'dependingTicketId' => '',
'milestoneid' => $params['dependentMilestone'],
'tags' => $params['tags'],
'editFrom' => $params['editFrom'] ?? '',
'editTo' => $params['editTo'] ?? '',
];
// Callers without the field (e.g. inline kanban edits) must not wipe a saved outcome.
if (array_key_exists('outcomeImpact', $params)) {
$values['outcomeImpact'] = $params['outcomeImpact'];
}
$values = $this->prepareTicketDates($values);
MilestoneUpdated::dispatch(milestoneId: $milestoneId, legacyHook: __FUNCTION__);
// $params is an array of field names. Exclude id
return $this->ticketRepository->updateTicket($values, $milestoneId);
}
/**
* Loads a milestone (or any ticket) by id for the milestone dialog.
*
* Wraps the repository directly (no project-assignment gate) to preserve
* the legacy milestone-dialog behavior where the controller used the
* repository and relied on its own current-project redirect logic.
*
* @param int $id The ticket/milestone id.
* @return TicketModel|bool The milestone model, or false if not found.
*
* @api
*/
#[RequiresPermission(TicketsPermissions::VIEW, projectIdParam: 'id')]
public function getMilestone(int $id): TicketModel|bool
{
$milestone = $this->ticketRepository->getTicket($id);
// Verify the user is assigned to the milestone's project.
// Mirrors the authorization check in getTicket().
if ($milestone && $this->projectService->isUserAssignedToProject(session('userdata.id'), $milestone->projectId)) {
return $milestone;
}
return false;
}
/**
* Builds a new (unsaved) milestone model with sensible defaults for the
* milestone dialog: status 3 ("New"), an edit window from today through
* one week from today.
*
* @return TicketModel The pre-populated milestone model.
*/
public function getNewMilestone(): TicketModel
{
$milestone = app()->make(TicketModel::class);
$milestone->status = 3;
$today = CarbonImmutable::now();
$milestone->editFrom = $today->format('Y-m-d');
$milestone->editTo = $today->addWeek()->format('Y-m-d');
return $milestone;
}
/**
* Adds a comment to a milestone and fires the milestone comment
* notification to project users. Mirrors the legacy EditMilestone
* controller flow exactly (comment add + dedicated milestone comment
* notification on top of the generic comment notification).
*
* @param array $params Request params containing 'id', 'text' and 'father'.
* @param mixed $milestone The milestone entity the comment belongs to (TicketModel or false when not found).
* @return bool True if the comment was added, false otherwise.
*
* @api
*/
#[RequiresPermission('comments.create', entityScoped: true)]
public function addMilestoneComment(array $params, mixed $milestone): bool
{
$milestoneId = (int) $params['id'];
$values = [
'text' => $params['text'],
'date' => date('Y-m-d H:i:s'),
'userId' => session('userdata.id'),
'moduleId' => $milestoneId,
'father' => $params['father'],
];
$messageId = $this->commentService->addComment($values, 'ticket', $milestoneId, $milestone);
if (! $messageId) {
return false;
}
$values['id'] = $messageId;
$subject = $this->language->__('email_notifications.new_comment_milestone_subject');
$actualLink = BASE_URL.'#/tickets/editMilestone/'.$milestoneId;
$message = sprintf($this->language->__('email_notifications.new_comment_milestone_message'), session('userdata.name'));
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => $actualLink,
'text' => $this->language->__('email_notifications.new_comment_milestone_cta'),
];
$notification->entity = $values;
$notification->module = 'comments';
$notification->action = 'commented';
$notification->projectId = session('currentProject');
$notification->subject = $subject;
$notification->authorId = session('userdata.id');
$notification->message = $message;
$this->projectService->notifyProjectUsers($notification);
return true;
}
/**
* Updates a milestone from the milestone dialog and, on success, fires the
* milestone-updated notification to project users. Wraps
* quickUpdateMilestone() so existing non-dialog callers keep their current
* (notification-free) behavior.
*
* @param array $params Milestone fields including 'id' and 'headline'.
* @return array|bool The quickUpdateMilestone result.
*
* @api
*/
#[RequiresPermission(TicketsPermissions::EDIT, entityScoped: true)]
public function updateMilestoneFromDialog(array $params): array|bool
{
$result = $this->quickUpdateMilestone($params);
// Only a genuine success (true) should notify and report success.
// quickUpdateMilestone returns a truthy error array on its failure paths
// (headline missing, denied/failed project move); those must not be read
// as a successful edit. Return false so the caller surfaces the
// save-error notification instead of a false success. (review)
if ($result !== true) {
return false;
}
$subject = $this->language->__('email_notifications.milestone_update_subject');
$actualLink = BASE_URL.'#/tickets/editMilestone/'.(int) $params['id'];
$message = sprintf($this->language->__('email_notifications.milestone_update_message'), session('userdata.name'));
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => $actualLink,
'text' => $this->language->__('email_notifications.milestone_update_cta'),
];
$notification->entity = $params;
$notification->module = 'tickets';
$notification->action = 'updated';
$notification->projectId = session('currentProject');
$notification->subject = $subject;
$notification->authorId = session('userdata.id');
$notification->message = $message;
$this->projectService->notifyProjectUsers($notification);
return $result;
}
/**
* Creates a milestone from the milestone dialog and, on success, fires the
* milestone-created notification to project users. Wraps
* quickAddMilestone() so existing non-dialog callers keep their current
* (notification-free) behavior.
*
* @param array $params Milestone fields including 'headline'.
* @return array|bool|int The new milestone id on success, otherwise the quickAddMilestone result.
*
* @api
*/
#[RequiresPermission(TicketsPermissions::CREATE, entityScoped: true)]
public function createMilestoneFromDialog(array $params): array|bool|int
{
$result = $this->quickAddMilestone($params);
if (! is_numeric($result)) {
return $result;
}
$params['id'] = $result;
$subject = $this->language->__('email_notifications.milestone_created_subject');
$actualLink = BASE_URL.'#/tickets/editMilestone/'.$result;
$message = sprintf($this->language->__('email_notifications.milestone_created_message'), session('userdata.name'));
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => $actualLink,
'text' => $this->language->__('email_notifications.milestone_created_cta'),
];
$notification->entity = $params;
$notification->module = 'tickets';
$notification->action = 'created';
$notification->projectId = session('currentProject');
$notification->subject = $subject;
$notification->authorId = session('userdata.id');
$notification->message = $message;
$this->projectService->notifyProjectUsers($notification);
return $result;
}
/**
* Resolves a client's display name by id. Returns an empty string when no
* id is given or the client cannot be found.
*
* @param int $clientId The client id (0 means "no client").
* @return string The client name, or an empty string.
*
* @api
*/
#[RequiresPermission(TicketsPermissions::VIEW)]
public function getClientNameById(int $clientId): string
{
if ($clientId <= 0) {
return '';
}
$client = $this->clientService->get($clientId);
if (is_array($client) && count($client) > 0 && isset($client['name'])) {
return $client['name'];
}
return '';
}
/**
* Normalizes roadmap/milestone-table request params: defaults the type to
* "milestone" and, when tasks should be shown, clears the type and
* exclude-type filters so tasks are included alongside milestones.
*
* @param array $params The incoming request params.
* @return array The normalized params.
*/
public function normalizeRoadmapParams(array $params): array
{
if (isset($params['type']) === false) {
$params['type'] = 'milestone';
}
if (isset($params['showTasks']) === true) {
$params['type'] = '';
$params['excludeType'] = '';
}
return $params;
}
/**
* Builds the milestone-overview search criteria. Identical to
* prepareTicketSearchArray() but applies the overview-only business rule
* of defaulting the status filter to "not_done" when none is selected, to
* reduce load and keep the table readable.
*
* @param array $params The incoming request params.
* @return array The search criteria with the overview status default applied.
*/
public function getMilestonesOverviewSearchCriteria(array $params): array
{
$searchCriteria = $this->prepareTicketSearchArray($params);
if ($searchCriteria['status'] == '') {
$searchCriteria['status'] = 'not_done';
}
return $searchCriteria;
}
/**
* Handles the Kanban quick-add flow: maps the active swimlane context onto
* the new task (priority, story points, milestone, editor, sprint, type or
* a due-date bucket), creates the task, and manages the
* stay-open/error reopen flash state. Returns a normalized result so the
* controller only has to set the notification and redirect.
*
* @param array $formParams Base quick-add fields (headline, status, milestone, sprint, projectId, editorId).
* @param string|null $swimlane The swimlane value the task was added under.
* @param string|null $groupBy The active Kanban grouping field.
* @param bool $stayOpen Whether the quick-add form should stay open after a successful add.
* @return array{success: bool, headline: string, status: mixed, message: string} Normalized result for the controller.
*
* @api
*/
#[RequiresPermission(TicketsPermissions::CREATE, entityScoped: true)]
public function quickAddTicketFromKanban(array $formParams, ?string $swimlane, ?string $groupBy, bool $stayOpen = false): array
{
if ($swimlane !== null && $swimlane !== '' && ! empty($groupBy)) {
// Map groupBy field to the parameter name expected by quickAddTicket()
$fieldMapping = [
'priority' => 'priority',
'storypoints' => 'storypoints',
'effort' => 'storypoints',
'milestoneid' => 'milestone',
'editorId' => 'editorId',
'sprint' => 'sprint',
'type' => 'type',
];
if (isset($fieldMapping[$groupBy])) {
$formParams[$fieldMapping[$groupBy]] = $swimlane;
} elseif ($groupBy === 'dueDate') {
// Map due date bucket names to actual dates
$now = dtHelper()->userNow();
$dueDateMapping = [
'overdue' => $now->formatDateForUser(),
'due-this-week' => $now->endOfWeek(CarbonImmutable::FRIDAY)->formatDateForUser(),
'due-next-week' => $now->addWeek()->endOfWeek(CarbonImmutable::FRIDAY)->formatDateForUser(),
'due-later' => $now->addWeeks(2)->formatDateForUser(),
];
if (isset($dueDateMapping[$swimlane])) {
$formParams['dateToFinish'] = $dueDateMapping[$swimlane];
}
}
}
$result = $this->quickAddTicket($formParams);
if (is_array($result) && isset($result['status']) && $result['status'] === 'error') {
session()->flash('quickadd_reopen', [
'status' => $formParams['status'],
'swimlane' => $swimlane,
'headline' => $formParams['headline'],
'error' => $result['message'],
]);
return [
'success' => false,
'headline' => $formParams['headline'],
'status' => $formParams['status'],
'message' => $result['message'],
];
}
if ($stayOpen) {
session()->flash('quickadd_reopen', [
'status' => $formParams['status'],
'swimlane' => $swimlane,
'headline' => '',
]);
}
return [
'success' => true,
'headline' => $formParams['headline'],
'status' => $formParams['status'],
'message' => '',
];
}
/**
* Quick-add a task from the program (cross-project) kanban.
*
* The board columns are semantic status types, so the status is resolved to a real
* status key in the explicitly chosen child project (a task belongs to exactly one
* project). Authorization (create in that project) is enforced by quickAddTicket().
*
* @api
*/
public function quickAddProgramTicket(array $formParams, ?string $swimlane, ?string $groupBy, bool $stayOpen, int $projectId, string $statusType): array
{
// Resolve the column's status type to a real key in the chosen project. May be null when
// the type can't be resolved — leave it null so quickAddTicket() applies its OWN safe
// project-NEW-status default (the single source of truth) rather than a hardcoded key.
$statusKey = $this->resolveProjectStatusKeyForType($projectId, $statusType)
?? $this->resolveProjectStatusKeyForType($projectId, 'NEW');
$formParams['projectId'] = $projectId;
$formParams['status'] = $statusKey;
// groupBy 'project' has no per-ticket field to set (the project is the picker choice),
// so the swimlane value is ignored for it by quickAddTicketFromKanban's field mapping.
return $this->quickAddTicketFromKanban($formParams, $swimlane, $groupBy, $stayOpen);
}
/**
* @api
*/
#[RequiresPermission(TicketsPermissions::CREATE, entityScoped: true)]
public function upsertSubtask($values, $parentTicket): bool
{
$this->authorize(TicketsPermissions::CREATE, (int) $parentTicket->projectId);
$subtaskId = $values['subtaskId'] ?? 'new';
$values = [
'headline' => $values['headline'],
'type' => 'subtask',
'description' => $values['description'] ?? '',
'projectId' => $parentTicket->projectId,
'editorId' => session('userdata.id'),
'userId' => session('userdata.id'),
'date' => $this->dateTimeHelper->userNow()->formatDateTimeForDb(),
'dateToFinish' => $values['dateToFinish'] ?? '',
'priority' => $values['priority'] ?? 3,
'status' => $values['status'],
'storypoints' => $values['storypoints'] ?? '',
'hourRemaining' => $values['hourRemaining'] ?? 0,
'planHours' => $values['planHours'] ?? 0,
'sprint' => '',
'acceptanceCriteria' => '',
'tags' => '',
'editFrom' => $values['editFrom'] ?? '',
'editTo' => $values['editTo'] ?? '',
'dependingTicketId' => $parentTicket->id,
'milestoneid' => $parentTicket->milestoneid,
];
$values = $this->prepareTicketDates($values);
if ($subtaskId == 'new' || $subtaskId == '') {
// New Ticket
if (! $this->ticketRepository->addTicket($values)) {
return false;
}
TicketCreated::dispatch(legacyHook: __FUNCTION__);
} else {
// Update Ticket
if (! $this->ticketRepository->updateTicket($values, $subtaskId)) {
return false;
}
TicketUpdated::dispatch(ticketId: (int) $subtaskId, legacyHook: __FUNCTION__);
}
return true;
}
/**
* Authorized JSON-RPC entry point for Gantt re-sorting of tickets/milestones.
*
* Enforces editor+ and per-ticket project access (the RPC endpoint has no
* controller-level gate), then delegates to the internal updateTicketSorting().
*
* @param array $params Array of ticketId => sortPosition from Gantt drag-drop
* @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 a referenced task's project
* @throws NotFoundException If a referenced task does not exist
*
* @api
*/
#[RequiresPermission(TicketsPermissions::EDIT, entityScoped: true)]
public function sortTickets(array $params): bool
{
if (! Auth::userIsAtLeast(Roles::$editor)) {
throw new AuthorizationException('You are not allowed to re-sort tasks.');
}
$userId = session('userdata.id');
foreach (array_keys($params) as $ticketId) {
$ticket = $this->getTicket((int) $ticketId);
if (! $ticket) {
throw new NotFoundException('A task referenced in the sort order could not be found.');
}
if (! $this->projectService->isUserAssignedToProject($userId, $ticket->projectId)) {
throw new AuthorizationException('You are not allowed to re-sort this task.');
}
}
return $this->updateTicketSorting($params);
}
/**
* Update ticket sorting with hierarchical cascade for milestone children
*
* When milestones are reordered, this method ensures all child tasks
* maintain their hierarchical relationship with their parent milestone.
* Uses a hierarchical sortindex scheme where:
* - Milestones: position * 100 (100, 200, 300, ...)
* - Tasks under milestone: milestoneSort + offset (101, 102, 103, ...)
*
* @param array $params Array of ticketId => sortPosition from Gantt drag-drop
* @return bool True on success, false on failure
*
* @internal Authorize via sortTickets() for JSON-RPC callers; safe for internal service use.
*/
public function updateTicketSorting($params): bool
{
if (empty($params)) {
return true;
}
$allUpdates = [];
// Fetch all tickets to determine types and relationships
$ticketIds = array_keys($params);
$tickets = [];
foreach ($ticketIds as $ticketId) {
$ticket = $this->getTicket($ticketId);
if ($ticket) {
$tickets[$ticketId] = [
'id' => $ticket->id,
'type' => $ticket->type,
'milestoneid' => $ticket->milestoneid,
'projectId' => $ticket->projectId,
];
}
}
// Separate TOP-LEVEL milestones from other tickets
// Top-level milestones have no milestoneid (or milestoneid = 0/null)
$topLevelMilestones = [];
$otherTickets = [];
foreach ($tickets as $ticketId => $ticket) {
if ($ticket['type'] === 'milestone' && empty($ticket['milestoneid'])) {
$topLevelMilestones[$ticketId] = $ticket;
} else {
$otherTickets[$ticketId] = $ticket;
}
}
// Process top-level milestones with hierarchical structure
foreach ($topLevelMilestones as $ticketId => $milestone) {
$position = $params[$ticketId];
// Calculate base sortindex for milestone (position * 100)
$baseSortIndex = $position * 100;
$allUpdates[$ticketId] = $baseSortIndex;
// Get all children for this milestone
$children = $this->getTicketChildren($ticketId, $milestone['projectId']);
if (! empty($children)) {
// Calculate hierarchical sortindex for all descendants
$childOffset = 1;
$childUpdates = $this->calculateHierarchicalSortIndex($children, $baseSortIndex, $childOffset);
// array_merge reindexes numeric keys, use + to preserve keys
foreach ($childUpdates as $childId => $childSort) {
$allUpdates[$childId] = $childSort;
}
}
}
// Process other tickets (non-top-level-milestones that were dragged)
// This includes sub-milestones and regular tasks
foreach ($otherTickets as $ticketId => $ticket) {
$position = $params[$ticketId];
// If this ticket belongs to a top-level milestone that was also updated,
// skip it (already handled above with hierarchical sorting)
if (! empty($ticket['milestoneid']) && isset($topLevelMilestones[$ticket['milestoneid']])) {
continue;
}
// For orphan tickets or tickets whose parent wasn't moved,
// assign sortindex based on position
// Use position * 100 to maintain spacing
$allUpdates[$ticketId] = $position * 100;
}
// Bulk update all sortindex values in a single transaction
$result = $this->ticketRepository->bulkUpdateSortIndex($allUpdates);
if ($result) {
TicketUpdated::dispatch(legacyHook: __FUNCTION__);
}
return $result;
}
/**
* @throws BindingResolutionException
*
* @api
*/
#[RequiresPermission(TicketsPermissions::EDIT, entityScoped: true)]
public function updateTicketStatusAndSorting($params, $handler = null): bool
{
if (! Auth::userIsAtLeast(Roles::$editor)) {
return false;
}
// Collect all ticket IDs from the request to validate project access for each one.
// This prevents smuggling cross-project ticket IDs in a mixed batch.
$allTicketIds = [];
if ($handler) {
$allTicketIds[] = substr($handler, 7);
}
foreach ($params as $status => $ticketList) {
if (is_numeric($status) && ! empty($ticketList)) {
$tickets = explode('&', $ticketList);
foreach ($tickets as $ticketString) {
$id = substr($ticketString, 9);
if (! empty($id)) {
$allTicketIds[] = $id;
}
}
}
}
// Verify project access for every ticket in the batch
$userId = session('userdata.id');
$checkedProjects = [];
foreach ($allTicketIds as $ticketId) {
$ticket = $this->getTicket($ticketId);
if (! $ticket) {
return false;
}
$projectId = $ticket->projectId;
// Cache project access checks to avoid redundant DB lookups
if (! isset($checkedProjects[$projectId])) {
$checkedProjects[$projectId] = $this->projectService->isUserAssignedToProject($userId, $projectId);
}
if (! $checkedProjects[$projectId]) {
return false;
}
}
// Jquery sortable serializes the array for kanban in format
// statusKey: ticket[]=X&ticket[]=X2...,
// statusKey2: ticket[]=X&ticket[]=X2...,
// This represents status & kanban sorting
foreach ($params as $status => $ticketList) {
if (is_numeric($status) && ! empty($ticketList)) {
$tickets = explode('&', $ticketList);
if (is_array($tickets) === true) {
foreach ($tickets as $key => $ticketString) {
$id = substr($ticketString, 9);
if ($this->ticketRepository->updateTicketStatus($id, $status, ($key * 100), $handler) === false) {
return false;
}
}
}
}
}
if ($handler) {
// Assumes format ticket_ID
$id = substr($handler, 7);
$ticket = $this->getTicket($id);
if ($ticket) {
$subject = sprintf($this->language->__('email_notifications.todo_update_subject'), $id, strip_tags($ticket->headline));
$actual_link = BASE_URL.'/dashboard/home#/tickets/showTicket/'.$id;
$message = sprintf($this->language->__('email_notifications.todo_update_message'), session('userdata.name'), strip_tags($ticket->headline));
$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') ?? -1;
$notification->message = $message;
$this->projectService->notifyProjectUsers($notification);
}
}
TicketUpdated::dispatch(legacyHook: __FUNCTION__);
return true;
}
// Delete
/**
* @return bool|string[]
*
* @throws BindingResolutionException
*
* @api
*/
#[RequiresPermission(TicketsPermissions::DELETE, entityScoped: true)]
public function delete($id): array|bool
{
$ticket = $this->getTicket($id);
if (! $ticket) {
return ['msg' => 'notifications.ticket_delete_error', 'type' => 'error'];
}
// Milestones carry goal tracked_by edges (and child-ticket links) that
// only deleteMilestone() cascades — deleting one through the generic
// path would strand orphaned edges. Route by actual type, not by which
// entry point the caller happened to use.
if (($ticket->type ?? '') === 'milestone') {
return $this->deleteMilestone($id);
}
// Editor+ in the ticket's project AND access to it (was access-only, no role gate).
$this->authorize(TicketsPermissions::DELETE, (int) $ticket->projectId);
// Collaborator relationship rows are cleaned up inside the repository's delticket().
if ($this->ticketRepository->delticket($id)) {
TicketDeleted::dispatch(ticketId: (int) $id, legacyHook: __FUNCTION__);
return true;
}
return false;
}
public function canDelete($id)
{
$ticket = $this->getTicket($id);
if (empty($ticket)) {
throw new \Exception('Task does not exist');
}
$hasLoggedHours = $this->timesheetsRepo->getTimesheetsByTicket($id);
if ($hasLoggedHours) {
throw new \Exception('Task has timesheets attached, delete all timesheets first or consider archiving the task');
}
return true;
}
/**
* @return bool|string[]
*
* @throws BindingResolutionException
*
* @api
*/
#[RequiresPermission(TicketsPermissions::DELETE, entityScoped: true)]
public function deleteMilestone($id): array|bool
{
$ticket = $this->getTicket($id);
if (! $ticket) {
return ['msg' => 'notifications.milestone_delete_error', 'type' => 'error'];
}
// Editor+ in the milestone's project AND access to it (was access-only, no role gate).
$this->authorize(TicketsPermissions::DELETE, (int) $ticket->projectId);
$this->ticketRepository->delMilestone($id);
// Drop this milestone's tracked_by links from any goals. (Refine: move
// to a MilestoneDeleted listener per the event-driven pattern.)
$this->goalcanvasService->detachMilestoneFromGoals((int) $id);
MilestoneDeleted::dispatch(milestoneId: (int) $id, legacyHook: __FUNCTION__);
return true;
}
/**
* @return mixed|string
*
* @api
*/
#[RequiresPermission(TicketsPermissions::VIEW)]
public function getLastTicketViewUrl(): mixed
{
$url = BASE_URL.'/tickets/showKanban';
if (session()->exists('lastTicketView') && session('lastTicketView') != '') {
if (session('lastTicketView') === 'kanban' && session()->exists('lastFilterdTicketKanbanView') && session('lastFilterdTicketKanbanView') != '') {
return session('lastFilterdTicketKanbanView');
}
if (session('lastTicketView') === 'table' && session()->exists('lastFilterdTicketTableView') && session('lastFilterdTicketTableView') != '') {
return session('lastFilterdTicketTableView');
}
if (session('lastTicketView') === 'list' && session()->exists('lastFilterdTicketListView') && session('lastFilterdTicketListView') != '') {
return session('lastFilterdTicketListView');
}
return $url;
} else {
return $url;
}
}
public function getLastTimelineViewUrl(): mixed
{
$url = BASE_URL.'/tickets/roadmap';
if (session()->exists('lastMilestoneView') && session('lastMilestoneView') != '') {
if (session('lastMilestoneView') === 'table' && session()->exists('lastFilterdMilestoneTableView') && session('lastFilterdMilestoneTableView') != '') {
return session('lastFilterdMilestoneTableView');
}
if (session('lastMilestoneView') === 'roadmap' && session()->exists('lastFilterdTicketRoadmapView') && session('lastFilterdTicketRoadmapView') != '') {
return session('lastFilterdTicketRoadmapView');
}
if (session('lastMilestoneView') === 'calendar' && session()->exists('lastFilterdTicketCalendarView') && session('lastFilterdTicketCalendarView') != '') {
return session('lastFilterdTicketCalendarView');
}
return $url;
} else {
return $url;
}
}
/**
* @api
*/
#[RequiresPermission(TicketsPermissions::VIEW)]
public function getGroupByFieldOptions(): array
{
// Alphabetically ordered (except "No Grouping" stays first as default)
return [
'all' => [
'id' => 'all',
'field' => 'all',
'class' => '',
'label' => 'no_group',
],
'dueDate' => [
'id' => 'dueDate',
'field' => 'dueDate',
'class' => '',
'label' => 'due_date',
],
'effort' => [
'id' => 'effort',
'field' => 'storypoints',
'label' => 'effort',
'class' => '',
'function' => 'getEffortLabels',
],
'milestone' => [
'id' => 'milestone',
'field' => 'milestoneid',
'label' => 'milestone',
'class' => '',
'function' => null,
],
'parentTask' => [
'id' => 'parentTask',
'field' => 'dependingTicketId',
'label' => 'parent_task',
'class' => '',
],
'priority' => [
'id' => 'priority',
'field' => 'priority',
'label' => 'priority',
'class' => '',
'function' => 'getPriorityLabels',
],
'project' => [
'id' => 'project',
'field' => 'projectId',
'label' => 'project',
'class' => '',
],
'sprint' => [
'id' => 'sprint',
'field' => 'sprint',
'class' => '',
'label' => 'sprint',
],
'status' => [
'id' => 'status',
'field' => 'status',
'label' => 'todo_status',
'class' => '',
'function' => 'getStatusLabels',
],
'type' => [
'id' => 'type',
'field' => 'type',
'label' => 'type',
'class' => '',
'function' => 'getTicketTypes',
],
'user' => [
'id' => 'user',
'field' => 'editorId',
'label' => 'user',
'class' => '',
'funtion' => 'buildEditorName',
],
];
}
/**
* @return array[]
*
* @api
*/
#[RequiresPermission(TicketsPermissions::VIEW)]
public function getSortByFieldOptions(): array
{
return [
[
'id' => 'sortByManualLink',
'status' => 'manualSort',
'label' => 'manualSort',
],
[
'id' => 'sortByTypeLink',
'status' => 'type',
'label' => 'type',
],
[
'id' => 'sortByStatusLink',
'status' => 'status',
'label' => 'todo_status',
],
[
'id' => 'sortByEffortLink',
'status' => 'effort',
'label' => 'effort',
],
[
'id' => 'sortByPriorityLink',
'status' => 'priority',
'label' => 'priority',
],
[
'id' => 'sortByMilestoneLink',
'status' => 'milestone',
'label' => 'milestone',
],
[
'id' => 'sortByUserLink',
'status' => 'user',
'label' => 'user',
],
[
'id' => 'sortBySprintLink',
'status' => 'sprint',
'label' => 'sprint',
],
[
'id' => 'sortByTagsLink',
'status' => 'tags',
'label' => 'tags',
],
[
'id' => 'sortByDueDateLink',
'status' => 'dateToFinish',
'label' => 'dueDate',
],
];
}
/**
* @return array|array[]
*
* @api
*/
#[RequiresPermission(TicketsPermissions::VIEW)]
public function getNewFieldOptions(): array
{
if (! defined('BASE_URL')) {
return [];
}
return [
[
'url' => '#/tickets/newTicket',
'text' => 'links.add_todo',
'class' => 'ticketModal',
],
[
'url' => '#/tickets/editMilestone',
'text' => 'links.add_milestone',
'class' => 'milestoneModal',
],
];
}
/**
* Compute at-a-glance board metrics (total, unassigned, due-this-week, last
* updated) from an already-fetched grouped ticket set. Working off the rows
* the board just rendered means the counts reflect the current filters and
* cost no extra query.
*
* @param array $groupedTickets Grouped tickets as returned by getAllGrouped()
* (each group holds its rows under 'items').
*
* @internal Board-header helper only; deliberately NOT @api, so it is not
* reachable via JSON-RPC (which would let a caller submit an
* arbitrarily large groupedTickets payload).
*/
public function getBoardSummary(array $groupedTickets): BoardSummary
{
$summary = new BoardSummary;
// Resolve the datetime helper once — it's used per ticket (twice) below,
// and dtHelper() resolves through the container, so caching it avoids
// avoidable overhead on large boards.
$dt = dtHelper();
// "This week" = today through the end of the current week, compared as
// calendar dates in the user's timezone (Y-m-d string compare is
// chronological) so a date-only due date lands in the intended week.
$userNow = $dt->userNow();
$weekStartDate = $userNow->format('Y-m-d');
$weekEndDate = $userNow->endOfWeek()->format('Y-m-d');
// Parse a DB date to CarbonImmutable, or null if it's unusable.
// isValidDateString() cheaply filters the common sentinels (empty,
// 0000-00-00, 1969-12-31); the try/catch then catches a genuinely
// malformed-but-non-sentinel value so one bad row can't throw and blank
// the whole board header.
$safeParse = static function (mixed $value) use ($dt): ?CarbonImmutable {
if (! $dt->isValidDateString($value !== null ? (string) $value : null)) {
return null;
}
try {
return $dt->parseDbDateTime((string) $value);
} catch (\Exception $e) {
return null;
}
};
foreach ($groupedTickets as $group) {
foreach ($group['items'] ?? [] as $ticket) {
$summary->total++;
$editorId = is_object($ticket) ? ($ticket->editorId ?? null) : ($ticket['editorId'] ?? null);
if (empty($editorId)) {
$summary->unassigned++;
}
$due = is_object($ticket) ? ($ticket->dateToFinish ?? null) : ($ticket['dateToFinish'] ?? null);
$dueDt = $safeParse($due);
if ($dueDt !== null) {
$dueDate = $dueDt->setToUserTimezone()->format('Y-m-d');
if ($dueDate >= $weekStartDate && $dueDate <= $weekEndDate) {
$summary->dueThisWeek++;
}
}
$modified = is_object($ticket) ? ($ticket->modified ?? null) : ($ticket['modified'] ?? null);
$modifiedDt = $safeParse($modified);
if ($modifiedDt !== null) {
if ($summary->lastUpdated === null || $modifiedDt->greaterThan($summary->lastUpdated)) {
$summary->lastUpdated = $modifiedDt;
}
}
}
}
return $summary;
}
public function getTicketTemplateAssignments($params): array
{
$currentSprint = $this->sprintService->getCurrentSprintId((int) session('currentProject'));
$searchCriteria = $this->prepareTicketSearchArray($params);
$searchCriteria['orderBy'] = 'kanbansort';
$allTickets = $this->getAllGrouped($searchCriteria);
$allTicketStates = $this->getStatusLabels();
$efforts = $this->getEffortLabels();
$priorities = $this->getPriorityLabels();
$types = $this->getTicketTypes();
// Types are being used for filters. Add milestone as a type
$types[] = 'milestone';
$ticketTypeIcons = $this->getTypeIcons();
$numOfFilters = $this->countSetFilters($searchCriteria);
$onTheClock = $this->timesheetService->isClocked(session('userdata.id'));
$sprints = $this->sprintService->getAllSprints(session('currentProject'));
$futureSprints = $this->sprintService->getAllFutureSprints((int) session('currentProject'));
$users = $this->projectService->getUsersWithAccessToProject((int) session('currentProject'));
$milestones = $this->getAllMilestones([
'sprint' => '',
'type' => 'milestone',
'currentProject' => session('currentProject'),
]);
$groupByOptions = $this->getGroupByFieldOptions();
$newField = $this->getNewFieldOptions();
$sortOptions = $this->getSortByFieldOptions();
$searchUrlString = '';
if ($numOfFilters > 0 || $searchCriteria['groupBy'] != '') {
$searchUrlString = '?'.http_build_query($this->getSetFilters($searchCriteria, true));
}
$allTickets = $this->enrichGroupedTicketsWithCollaborators($allTickets);
$allTickets = TicketListFilter::dispatch(tickets: $allTickets, legacyHook: __FUNCTION__);
return [
'currentSprint' => session('currentSprint'),
'searchCriteria' => $searchCriteria,
'allTickets' => $allTickets,
'boardSummary' => $this->getBoardSummary($allTickets),
'allTicketStates' => $allTicketStates,
'efforts' => $efforts,
'priorities' => $priorities,
'types' => $types,
'ticketTypeIcons' => $ticketTypeIcons,
'numOfFilters' => $numOfFilters,
'onTheClock' => $onTheClock,
'sprints' => $sprints,
'futureSprints' => $futureSprints,
'users' => $users,
'milestones' => $milestones,
'groupByOptions' => $groupByOptions,
'newField' => $newField,
'sortOptions' => $sortOptions,
'searchParams' => $searchUrlString,
];
}
/**
* Build the template assignments for a program-level cross-project task board.
*
* Mirrors getTicketTemplateAssignments() but scopes the ticket query to a set of
* child project ids (instead of the single session project), aggregates the
* supporting datasets (users, milestones) across those projects, and surfaces the
* data a cross-project board needs: per-project status labels (so a task is always
* rendered/edited with statuses that exist in ITS OWN project) and the semantic
* status-type columns used by the program kanban.
*
* @param array $params Incoming request/search parameters.
* @param int $programId The program project id (owns the shared sprints + canonical labels).
* @param int[] $childIds The child project ids the board spans (already authorized by the caller).
* @param array<int, string> $availableProjects id => name map used by the project filter/grouping.
*
* @api
*/
public function getProgramTicketTemplateAssignments(array $params, int $programId, array $childIds, array $availableProjects): array
{
$childIds = array_values(array_filter(array_map('intval', $childIds), static fn ($id) => $id > 0));
$searchCriteria = $this->prepareTicketSearchArray($params);
// Neutralize the single-project filter (currentProject is the program, which owns no
// tickets); the board is scoped to the child projects via `projects` instead.
$searchCriteria['currentProject'] = '';
$searchCriteria['orderBy'] = 'kanbansort';
// The sidebar "Project" filter narrows the board, but only ever WITHIN the authorized
// child set — request-supplied project ids are never trusted on their own. When no
// project is picked, the board spans every authorized child.
$requestedProjects = array_values(array_filter(
array_map('intval', explode(',', (string) ($searchCriteria['projects'] ?? ''))),
static fn ($id) => $id > 0
));
$effectiveProjectIds = $requestedProjects !== []
? array_values(array_intersect($requestedProjects, $childIds))
: $childIds;
// Query with the effective (intersected) set; keep $searchCriteria['projects'] as the
// requested value so the filter UI + tab links reflect the user's selection.
$queryCriteria = $searchCriteria;
$queryCriteria['projects'] = implode(',', $effectiveProjectIds);
$allTickets = $effectiveProjectIds === []
? ['all' => ['label' => 'all', 'id' => 'all', 'value' => '', 'class' => '', 'items' => []]]
: $this->getAllGrouped($queryCriteria);
// Per-project status labels: a task must be displayed and editable with statuses that
// actually exist in its own project, never the program's, so a status change can never
// orphan it off the project board.
$statusLabelsByProject = [];
foreach ($childIds as $pid) {
$statusLabelsByProject[$pid] = $this->ticketRepository->getStateLabels($pid);
}
// Annotate each ticket with the semantic status type resolved from its own project,
// used for cross-project kanban column placement and status grouping.
$allTickets = $this->annotateTicketsWithStatusType($allTickets, $statusLabelsByProject);
$efforts = $this->getEffortLabels();
$priorities = $this->getPriorityLabels();
$types = $this->getTicketTypes();
$types[] = 'milestone';
$ticketTypeIcons = $this->getTypeIcons();
$numOfFilters = $this->countSetFilters($searchCriteria);
$onTheClock = $this->timesheetService->isClocked(session('userdata.id'));
// Sprints are owned by the program and shared across child projects.
$sprints = $this->sprintService->getAllSprints($programId);
$futureSprints = $this->sprintService->getAllFutureSprints($programId);
// Aggregate assignable users + milestones across the child projects (deduped).
$users = [];
$milestones = [];
if ($childIds !== []) {
// Milestones: a single query across all child projects via the multi-project filter,
// rather than one (heavy, joined) query per project.
foreach ($this->getAllMilestones(['sprint' => '', 'type' => 'milestone', 'projects' => implode(',', $childIds)]) as $milestone) {
$milestones[$milestone->id] = $milestone;
}
// Users: one light indexed lookup per child project (bounded by child count).
// getUsersWithAccessToProject applies each project's psettings/client access
// rules, so we keep the per-project call and dedupe by id.
foreach ($childIds as $pid) {
foreach ($this->projectService->getUsersWithAccessToProject((int) $pid) as $user) {
$users[$user['id']] = $user;
}
}
}
$users = array_values($users);
$milestones = array_values($milestones);
$groupByOptions = $this->getGroupByFieldOptions();
$newField = $this->getNewFieldOptions();
$sortOptions = $this->getSortByFieldOptions();
$searchUrlString = '';
if ($numOfFilters > 0 || $searchCriteria['groupBy'] != '') {
$searchUrlString = '?'.http_build_query($this->getSetFilters($searchCriteria, true));
}
$allTickets = $this->enrichGroupedTicketsWithCollaborators($allTickets);
$allTickets = TicketListFilter::dispatch(tickets: $allTickets, legacyHook: __FUNCTION__);
$statusTypeColumns = $this->getStatusTypeColumns();
return [
'currentSprint' => session('currentSprint'),
'searchCriteria' => $searchCriteria,
'allTickets' => $allTickets,
// For table/list the program's own labels are a sensible fallback; per-row status
// rendering uses statusLabelsByProject. For the kanban the controller assigns
// statusTypeColumns as allKanbanColumns.
'allTicketStates' => $this->ticketRepository->getStateLabels($programId),
'statusLabelsByProject' => $statusLabelsByProject,
'statusTypeColumns' => $statusTypeColumns,
'efforts' => $efforts,
'priorities' => $priorities,
'types' => $types,
'ticketTypeIcons' => $ticketTypeIcons,
'numOfFilters' => $numOfFilters,
'onTheClock' => $onTheClock,
'sprints' => $sprints,
'futureSprints' => $futureSprints,
'users' => $users,
'milestones' => $milestones,
'groupByOptions' => $groupByOptions,
'newField' => $newField,
'sortOptions' => $sortOptions,
'searchParams' => $searchUrlString,
'availableProjects' => $availableProjects,
'programBoard' => true,
];
}
/**
* The semantic status-type columns used by a cross-project (program) kanban board.
* Keyed by the lowercased status type so tickets can be placed by their computed
* statusType and the board CSS classes stay consistent (status_new, etc.).
*
* @return array<string, array<string, mixed>>
*/
private function getStatusTypeColumns(): array
{
return [
'new' => ['name' => $this->language->__('status.new'), 'class' => 'label-info', 'statusType' => 'NEW', 'kanbanCol' => true, 'sortKey' => 1],
'inprogress' => ['name' => $this->language->__('status.in_progress'), 'class' => 'label-warning', 'statusType' => 'INPROGRESS', 'kanbanCol' => true, 'sortKey' => 2],
'done' => ['name' => $this->language->__('status.done'), 'class' => 'label-success', 'statusType' => 'DONE', 'kanbanCol' => true, 'sortKey' => 3],
];
}
/**
* Annotate each ticket in a grouped collection with the lowercased semantic status type
* resolved from its own project's labels. Used to place cross-project tickets into the
* shared status-type columns / groups without losing each project's real status key.
*
* @param array<string, array<string, mixed>> $groupedTickets
* @param array<int, array> $statusLabelsByProject
* @return array<string, array<string, mixed>>
*/
private function annotateTicketsWithStatusType(array $groupedTickets, array $statusLabelsByProject): array
{
foreach ($groupedTickets as $groupKey => $group) {
if (! isset($group['items']) || ! is_array($group['items'])) {
continue;
}
foreach ($group['items'] as $index => $ticket) {
$projectId = (int) ($ticket['projectId'] ?? 0);
$statusKey = $ticket['status'] ?? null;
$labels = $statusLabelsByProject[$projectId] ?? [];
// A ticket can carry a status key no longer defined in its project; isset() keeps
// the nested lookup warning-free and falls back to NEW.
$type = isset($labels[$statusKey]['statusType']) ? $labels[$statusKey]['statusType'] : 'NEW';
$groupedTickets[$groupKey]['items'][$index]['statusType'] = strtolower($type);
}
}
return $groupedTickets;
}
/**
* Adds collaborator display metadata to grouped ticket collections used by list/kanban views.
*
* @param array<string, array<string, mixed>> $groupedTickets
* @return array<string, array<string, mixed>>
*/
private function enrichGroupedTicketsWithCollaborators(array $groupedTickets): array
{
$ticketIds = [];
foreach ($groupedTickets as $group) {
// Support both 'items' (list/kanban views) and 'tickets' (ToDoWidget views)
$items = $group['items'] ?? $group['tickets'] ?? [];
foreach ($items as $ticket) {
if (isset($ticket['id'])) {
$ticketIds[] = (int) $ticket['id'];
}
}
}
if (empty($ticketIds)) {
return $groupedTickets;
}
$collaboratorsByTicket = $this->ticketRepository->getCollaboratorsByTicketIds($ticketIds);
foreach ($groupedTickets as &$group) {
// Determine which key holds the ticket array
if (isset($group['items']) && is_array($group['items'])) {
$key = 'items';
} elseif (isset($group['tickets']) && is_array($group['tickets'])) {
$key = 'tickets';
} else {
continue;
}
foreach ($group[$key] as &$ticket) {
$ticketId = (int) ($ticket['id'] ?? 0);
$editorId = (int) ($ticket['editorId'] ?? 0);
$collaboratorIds = $collaboratorsByTicket[$ticketId] ?? [];
// Do not duplicate the primary assignee in the collaborator UI stack.
if ($editorId > 0) {
$collaboratorIds = array_values(array_filter(
$collaboratorIds,
fn ($userId) => (int) $userId !== $editorId
));
}
$ticket['collaborators'] = $collaboratorIds;
$ticket['collaboratorPreview'] = array_slice($collaboratorIds, 0, 2);
$ticket['collaboratorCount'] = count($collaboratorIds);
$ticket['collaboratorOverflow'] = max(0, count($collaboratorIds) - count($ticket['collaboratorPreview']));
}
unset($ticket);
}
unset($group);
return $groupedTickets;
}
/**
* Retrieves the assignments for the ToDoWidget.
*
* @param array $params The parameters for filtering the assignments.
* - projectFilter (optional): The project filter for the assignments.
* - groupBy (optional): The grouping for the assignments (time, project, priority, or sprint).
* @return array An array containing the assignments for the ToDoWidget.
* - tickets: The open user tickets based on the groupBy parameter.
* - onTheClock: Indicates whether the user is currently clocked in.
* - efforts: The labels for the effort values.
* - priorities: The labels for the priority values.
* - ticketTypes: The available ticket types.
* - statusLabels: The labels for the ticket status values.
* - milestones: The milestones for each project.
* - allAssignedprojects: The projects assigned to the user.
* - projectFilter: The current project filter.
* - groupBy: The current grouping for the assignments.
*/
public function getToDoWidgetAssignments($params)
{
$projectFilter = '';
if (session()->exists('userHomeProjectFilter')) {
$projectFilter = session('userHomeProjectFilter');
}
if (isset($params['projectFilter'])) {
$projectFilter = $params['projectFilter'] !== 'all' ? $params['projectFilter'] : '';
session(['userHomeProjectFilter' => $projectFilter]);
}
$groupBy = '';
if (session()->exists('userHomeGroupBy')) {
$groupBy = session('userHomeGroupBy');
}
if (isset($params['groupBy'])) {
$groupBy = $params['groupBy'];
session(['userHomeGroupBy' => $groupBy]);
}
if ($groupBy == '') {
$groupBy = 'time';
}
$tickets = [];
if ($groupBy === 'time') {
$tickets = $this->getOpenUserTicketsThisWeekAndLater(userId: session('userdata.id'), projectId: $projectFilter, includeMilestones: true);
} elseif ($groupBy === 'project') {
$tickets = $this->getOpenUserTicketsByProject(userId: session('userdata.id'), projectId: $projectFilter, includeMilestones: true);
} elseif ($groupBy === 'priority') {
$tickets = $this->getOpenUserTicketsByPriority(userId: session('userdata.id'), projectId: $projectFilter, includeMilestones: true);
} elseif ($groupBy === 'sprint') {
$tickets = $this->getOpenUserTicketsBySprint(userId: session('userdata.id'), projectId: $projectFilter, includeMilestones: true);
}
$onTheClock = $this->timesheetService->isClocked(session('userdata.id'));
$effortLabels = $this->getEffortLabels();
$priorityLabels = $this->getPriorityLabels();
$ticketTypes = $this->getTicketTypes();
$statusLabels = $this->getAllStatusLabelsByUserId(session('userdata.id'));
$milestoneArray = [];
foreach ($tickets as $ticketGroup) {
foreach ($ticketGroup['tickets'] as $ticket) {
if (isset($milestoneArray[$ticket['projectId']])) {
continue;
} else {
$milestoneArray[$ticket['projectId']] = $this->getAllMilestones(['sprint' => '', 'type' => 'milestone', 'currentProject' => $ticket['projectId']]);
}
}
}
$tickets = $this->enrichGroupedTicketsWithCollaborators($tickets);
$tickets = TodoWidgetTasksFilter::dispatch(tickets: $tickets, legacyHook: __FUNCTION__);
return [
'tickets' => $tickets,
'onTheClock' => $onTheClock,
'efforts' => $effortLabels,
'priorities' => $priorityLabels,
'ticketTypes' => $ticketTypes,
'statusLabels' => $statusLabels,
'milestones' => $milestoneArray,
'allAssignedprojects' => $this->projectService->getProjectsAssignedToUser(session('userdata.id'), 'open'),
'projectFilter' => $projectFilter,
'groupBy' => $groupBy,
];
}
/**
* Retrieves the hierarchical assignments for the ToDoWidget.
*
* @param array $params The parameters for filtering the assignments.
* @return array An array containing the hierarchical assignments for the ToDoWidget.
*/
public function getToDoWidgetHierarchicalAssignments($params)
{
$projectFilter = '';
if (session()->exists('userHomeProjectFilter')) {
$projectFilter = session('userHomeProjectFilter');
}
if (isset($params['projectFilter'])) {
$projectFilter = $params['projectFilter'] !== 'all' ? $params['projectFilter'] : '';
session(['userHomeProjectFilter' => $projectFilter]);
}
$groupBy = '';
if (session()->exists('userHomeGroupBy')) {
$groupBy = session('userHomeGroupBy');
}
if (isset($params['groupBy'])) {
$groupBy = $params['groupBy'];
session(['userHomeGroupBy' => $groupBy]);
}
if ($groupBy == '') {
$groupBy = 'time';
}
// Pagination parameters
// $limit = $params['limit'] ?? 20;
$limit = null;
$offset = $params['offset'] ?? 0;
$group = $params['group'] ?? null; // Specific group to load
$userId = session('userdata.id');
$sortingKey = "user.{$userId}.myTodosSorting";
$userSorting = $this->settingsRepo->getSetting($sortingKey);
// Get ALL tickets with global pagination first, then group them
$searchCriteria = $this->prepareTicketSearchArray([
'currentProject' => $projectFilter,
'currentUser' => session('userdata.id'),
'users' => session('userdata.id'),
'status' => 'not_done',
'sprint' => '',
]);
// Get paginated tickets first
$allTickets = $this->ticketRepository->getAllBySearchCriteria(
searchCriteria: $searchCriteria,
sort: $groupBy === 'priority' ? 'priority' : 'duedate',
limit: $limit,
includeCounts: false,
offset: $offset
);
// Then apply grouping based on groupBy parameter
$tickets = $this->applyGroupingToTickets($allTickets, $groupBy, session('userdata.id'));
$sortingArray = false;
if ($userSorting) {
$sortingArray = json_decode($userSorting, true);
}
// Get all milestones that have tasks assigned to the user
$allMilestones = [];
$milestoneIds = [];
$milestoneCache = [];
// First collect all milestone IDs from the user's tasks
foreach ($tickets as $groupKey => &$ticketGroup) {
if (isset($ticketGroup['tickets']) && is_array($ticketGroup['tickets'])) {
foreach ($ticketGroup['tickets'] as $ticket) {
if (! empty($ticket['milestoneid']) &&
(is_string($ticketGroup['groupValue']) || is_int($ticketGroup['groupValue'])) &&
! in_array($ticket['milestoneid'], $milestoneIds[$ticketGroup['groupValue']] ?? [])) {
$milestoneId = $ticket['milestoneid'];
if (! isset($milestoneCache[$milestoneId])) {
// getTicket() returns false when the user can't access the milestone's
// project (e.g. a cross-project milestone linked to a goal). Casting false
// to an array injects an empty "ticket" that later 500s the widget render,
// so cache null and skip it instead.
$milestone = $this->getTicket($milestoneId);
$milestoneCache[$milestoneId] = $milestone ? (array) $milestone : null;
}
if ($milestoneCache[$milestoneId] !== null) {
$ticketGroup['tickets'][] = $milestoneCache[$milestoneId];
$milestoneIds[$ticketGroup['groupValue']][] = $milestoneId;
}
}
}
}
}
// Fetch the milestone data for all collected milestone IDs
// if (! empty($milestoneIds)) {
// foreach ($milestoneIds as $milestoneId) {
// $milestone = $this->getTicket($milestoneId);
// if ($milestone) {
//
// // Add milestone to the appropriate group
// foreach ($tickets as $groupKey => &$ticketGroup) {
// // Add the milestone to each group that contains tasks belonging to this milestone
// foreach ($ticketGroup['tickets'] as $key => $ticket) {
// if (! empty($ticket['milestoneid']) && $ticket['milestoneid'] == $milestoneId) {
// // Create milestone entry if it doesn't exist in this group yet
// $milestoneEntry = (array) $milestone;
// $milestoneEntry['percentDone'] = $progress;
//
// // Add to the beginning of the group
// array_unshift($ticketGroup['tickets'], $milestoneEntry);
// break; // Only add once per group
// }
// }
// }
// }
// }
// }
// Enrich while tickets are still flat — buildTicketHierarchy() nests subtasks into
// 'children', so enriching afterwards would only reach the root rows.
$tickets = $this->enrichGroupedTicketsWithCollaborators($tickets);
// Process tickets to build hierarchical structure
foreach ($tickets as $groupKey => &$ticketGroup) {
if (isset($ticketGroup['tickets']) && is_array($ticketGroup['tickets'])) {
$ticketGroup['tickets'] = $this->buildTicketHierarchy($ticketGroup['tickets'], $sortingArray);
}
}
unset($ticketGroup);
$onTheClock = $this->timesheetService->isClocked(session('userdata.id'));
$effortLabels = $this->getEffortLabels();
$priorityLabels = $this->getPriorityLabels();
$ticketTypes = $this->getTicketTypes();
$statusLabels = $this->getAllStatusLabelsByUserId(session('userdata.id'));
$milestoneArray = [];
foreach ($tickets as &$ticketGroup) {
foreach ($ticketGroup['tickets'] as $ticket) {
if (isset($milestoneArray[$ticket['projectId']])) {
continue;
} else {
$milestoneArray[$ticket['projectId']] = $this->getAllMilestones(['sprint' => '', 'type' => 'milestone', 'currentProject' => $ticket['projectId']]);
}
}
}
unset($ticketGroup);
// Collaborators were enriched above, before the hierarchy was built.
$tickets = TodoWidgetTasksFilter::dispatch(tickets: $tickets, hierarchical: true, legacyHook: __FUNCTION__);
return [
'tickets' => $tickets,
'onTheClock' => $onTheClock,
'efforts' => $effortLabels,
'priorities' => $priorityLabels,
'ticketTypes' => $ticketTypes,
'statusLabels' => $statusLabels,
'milestones' => $milestoneArray,
'allAssignedprojects' => $this->projectService->getProjectsAssignedToUser(
session('userdata.id'),
'open'
),
'projectFilter' => $projectFilter,
'groupBy' => $groupBy,
];
}
/**
* Apply grouping logic to a set of tickets based on groupBy parameter
*/
private function applyGroupingToTickets(array $allTickets, string $groupBy, int $userId): array
{
$statusLabels = $this->getAllStatusLabelsByUserId($userId);
$tickets = [];
foreach ($allTickets as $row) {
// Skip tickets that are done or don't have proper status labels
if (! isset($statusLabels[$row['projectId']]) ||
! isset($statusLabels[$row['projectId']][$row['status']]) ||
$statusLabels[$row['projectId']][$row['status']]['statusType'] === 'DONE') {
continue;
}
$groupKey = $this->getGroupKeyForTicket($row, $groupBy);
$groupLabel = $this->getGroupLabelForTicket($row, $groupBy, $groupKey);
if (isset($tickets[$groupKey])) {
$tickets[$groupKey]['tickets'][] = $row;
} else {
$tickets[$groupKey] = [
'labelName' => $groupLabel,
'tickets' => [$row],
'groupValue' => $groupKey,
'order' => $this->getGroupOrder($groupKey, $groupBy),
];
}
}
// Sort groups by their order
uasort($tickets, function ($a, $b) {
return $a['order'] <=> $b['order'];
});
return $tickets;
}
/**
* Get the group key for a ticket based on grouping type
*/
private function getGroupKeyForTicket(array $ticket, string $groupBy): string
{
switch ($groupBy) {
case 'time':
if ($ticket['dateToFinish'] == '0000-00-00 00:00:00' ||
$ticket['dateToFinish'] == '1969-12-31 00:00:00' ||
$ticket['dateToFinish'] == null) {
return 'later';
}
$today = dtHelper()->userNow()->setToDbTimezone();
try {
$dbDueDate = dtHelper()->parseDbDateTime($ticket['dateToFinish']);
} catch (\Exception $e) {
return 'later';
}
if ($dbDueDate->lt($today->startOfDay())) {
return 'overdue';
} elseif ($dbDueDate->lte($today->endOfWeek())) {
return 'thisWeek';
} else {
return 'later';
}
case 'project':
return (string) $ticket['projectId'];
case 'priority':
return (string) ($ticket['priority'] ?: 999);
case 'sprint':
return (string) ($ticket['sprint'] ?: 'backlog');
default:
return 'default';
}
}
/**
* Get the display label for a group
*/
private function getGroupLabelForTicket(array $ticket, string $groupBy, string $groupKey): string
{
switch ($groupBy) {
case 'time':
switch ($groupKey) {
case 'overdue': return 'subtitles.overdue';
case 'thisWeek': return 'subtitles.due_this_week';
case 'later': return 'subtitles.due_later';
default: return 'subtitles.due_later';
}
case 'project':
return $ticket['clientName'].' / '.$ticket['projectName'];
case 'priority':
if ($groupKey === '999') {
return $this->language->__('label.priority_not_defined');
}
return $this->ticketRepository->priority[$groupKey] ?? 'Unknown Priority';
case 'sprint':
if ($groupKey === 'backlog') {
return $ticket['projectName'].' / '.$this->language->__('label.not_assigned_to_sprint');
}
return $ticket['projectName'].' / '.($ticket['sprintName'] ?: 'Sprint '.$groupKey);
default:
return 'Default Group';
}
}
/**
* Get the sort order for a group
*/
private function getGroupOrder(string $groupKey, string $groupBy): int
{
switch ($groupBy) {
case 'time':
switch ($groupKey) {
case 'overdue': return 1;
case 'thisWeek': return 2;
case 'later': return 3;
default: return 999;
}
case 'priority':
return (int) $groupKey;
default:
return 100; // Default order for project and sprint grouping
}
}
/**
* Build a hierarchical structure of tickets based on dependencies and milestones
*
* @param array $tickets Flat array of tickets
* @param array $sortingArray array of ticket ids and custom sorting index
* @return array Hierarchical array of tickets
*/
private function buildTicketHierarchy($tickets, array|bool $sortingArray = false)
{
$ticketMap = [];
$rootTickets = [];
// First pass: create a map of all tickets by ID
foreach ($tickets as $ticket) {
if (! isset($ticket['id'])) {
continue;
}
$ticket['children'] = [];
if ($sortingArray) {
$sortIndex = collect($sortingArray)->firstWhere('id', $ticket['id']);
$ticket['sortIndex'] = $sortIndex['order'] ?? $ticket['sortindex'] ?? 10;
}
$ticketMap[$ticket['id']] = $ticket;
}
// Second pass: build the hierarchy
foreach ($tickets as $ticket) {
if (! isset($ticket['id'])) {
continue;
}
// If this ticket has a parent (depending ticket)
if (! empty($ticket['dependingTicketId']) && isset($ticketMap[$ticket['dependingTicketId']])) {
// Add this ticket as a child of its parent
$ticketMap[$ticket['dependingTicketId']]['children'][] = &$ticketMap[$ticket['id']];
} // If this ticket belongs to a milestone
else {
if (! empty($ticket['milestoneid']) && isset($ticketMap[$ticket['milestoneid']])) {
// Add this ticket as a child of its milestone
$ticketMap[$ticket['milestoneid']]['children'][] = &$ticketMap[$ticket['id']];
} // Otherwise, it's a root ticket
else {
$rootTickets[] = &$ticketMap[$ticket['id']];
}
}
}
// Sort the tickets at each level
$this->sortTicketsRecursively($rootTickets);
return $rootTickets;
}
/**
* Sort tickets recursively at each level of the hierarchy
*
* @param array &$tickets Array of tickets to sort
*/
private function sortTicketsRecursively(&$tickets)
{
// Sort the current level by sortIndex
usort($tickets, function ($a, $b) {
return ($a['sortIndex'] ?? 0) - ($b['sortIndex'] ?? 0);
});
// Recursively sort children
foreach ($tickets as &$ticket) {
if (! empty($ticket['children'])) {
$this->sortTicketsRecursively($ticket['children']);
}
}
}
/**
* Get all children (tasks and subtasks) for a given ticket recursively
*
* @param int $ticketId The parent ticket ID
* @param int|null $projectId The project ID
* @return array Array of all descendants with nested structure
*/
private function getTicketChildren(int $ticketId, ?int $projectId = null): array
{
$children = [];
// Get direct children (tasks under milestone or subtasks under task)
$directChildren = $this->ticketRepository->getSubtasksByParent($ticketId);
// If this is a milestone, also get tasks assigned to it
$tasksInMilestone = $this->ticketRepository->getTasksByMilestone($ticketId, $projectId);
// Merge both lists
$allChildren = array_merge($directChildren, $tasksInMilestone);
// Remove duplicates (in case a task is both a subtask and assigned to milestone)
$uniqueChildren = [];
foreach ($allChildren as $child) {
if (! isset($uniqueChildren[$child['id']])) {
$uniqueChildren[$child['id']] = $child;
}
}
// Recursively get children for each child
foreach ($uniqueChildren as $child) {
$child['children'] = $this->getTicketChildren($child['id'], $projectId);
$children[] = $child;
}
return $children;
}
/**
* Calculate hierarchical sortindex values for a tree of tickets
*
* @param array $tickets Array of tickets to assign sortindex
* @param int $baseIndex The base sortindex (e.g., 100 for first milestone)
* @param int $offset Current offset within the base (starts at 1)
* @return array Array of ['ticketId' => sortindex]
*/
private function calculateHierarchicalSortIndex(array $tickets, int $baseIndex, int &$offset = 1): array
{
$updates = [];
foreach ($tickets as $ticket) {
$ticketId = $ticket['id'];
// Check if we've exceeded the 99-slot limit per parent
if ($offset > 99) {
Log::warning("Ticket hierarchy exceeds 99 children for base index {$baseIndex}. Ticket {$ticketId} will use overflow slot.");
}
// Assign sortindex: baseIndex + offset
$sortIndex = $baseIndex + $offset;
$updates[$ticketId] = $sortIndex;
// Recursively handle children if present
if (! empty($ticket['children'])) {
$childOffset = 1;
$childUpdates = $this->calculateHierarchicalSortIndex(
$ticket['children'],
$sortIndex,
$childOffset
);
$updates = array_merge($updates, $childUpdates);
// Update offset to account for all children
$offset += $childOffset;
} else {
$offset++;
}
}
return $updates;
}
/**
* Prepare ticket dates for database.
*
* @param array $values The values of the ticket fields.
* @return array The values of the ticket fields after preparing the dates.
*/
public function prepareTicketDates(&$values)
{
// Prepare dates for db
if (! empty($values['dateToFinish'])) {
try {
if ($values['dateToFinish'] instanceof CarbonImmutable) {
$values['dateToFinish'] = $values['dateToFinish']->formatDateTimeForDb();
} elseif (
is_string($values['dateToFinish'])
&& empty($values['timeToFinish'])
&& preg_match('/^\d{4}-\d{2}-\d{2}$/', $values['dateToFinish'])
) {
// Calendar-date input (e.g. "2026-05-21" from mobile) — store as
// midnight without any timezone conversion.
//
// dateToFinish is semantically a calendar-date field: "due on
// this day on the user's calendar", no time, no TZ. Running it
// through parseUserDateTime (which interprets it as user-local
// wall-clock end-of-day and converts to UTC for storage) caused
// the visible "Hi Claude due May 21 stored as May 22" bug, because
// the LA-default end-of-day rolls past UTC midnight. Per the
// mobile audit (date-handling Tier 1), calendar-date fields
// should round-trip as YYYY-MM-DD strings with no TZ math.
//
// This branch only activates when:
// - dateToFinish looks like "YYYY-MM-DD" with no time, AND
// - timeToFinish is absent
// so existing web UI paths that submit dates with companion times
// continue to use parseUserDateTime unchanged.
$values['dateToFinish'] = $values['dateToFinish'].' 00:00:00';
unset($values['timeToFinish']);
} else {
if (isset($values['timeToFinish']) && $values['timeToFinish'] != null) {
$values['dateToFinish'] = dtHelper()->parseUserDateTime(
$values['dateToFinish'],
$values['timeToFinish']
)->formatDateTimeForDb();
unset($values['timeToFinish']);
} else {
$values['dateToFinish'] = dtHelper()->parseUserDateTime(
$values['dateToFinish'],
'end'
)->formatDateTimeForDb();
}
}
} catch (\Exception $e) {
$values['dateToFinish'] = '';
unset($values['timeToFinish']);
}
}
if (! empty($values['editFrom'])) {
try {
if ($values['editFrom'] instanceof CarbonImmutable) {
$values['editFrom'] = $values['editFrom']->formatDateTimeForDb();
} else {
if (isset($values['timeFrom']) && $values['timeFrom'] != null) {
$values['editFrom'] = dtHelper()->parseUserDateTime(
$values['editFrom'],
$values['timeFrom'],
)->formatDateTimeForDb();
unset($values['timeFrom']);
} else {
$values['editFrom'] = dtHelper()->parseUserDateTime(
$values['editFrom'],
'start'
)->formatDateTimeForDb();
}
}
} catch (\Exception $e) {
$values['editFrom'] = '';
unset($values['timeFrom']);
}
}
if (! empty($values['editTo'])) {
try {
if ($values['editTo'] instanceof CarbonImmutable) {
$values['editTo'] = $values['editTo']->formatDateTimeForDb();
} else {
if (isset($values['timeTo']) && $values['timeTo'] != null) {
$values['editTo'] = dtHelper()->parseUserDateTime(
$values['editTo'],
$values['timeTo']
)->formatDateTimeForDb();
unset($values['timeTo']);
} else {
$values['editTo'] = dtHelper()->parseUserDateTime(
$values['editTo'],
'end'
)->formatDateTimeForDb();
}
}
} catch (\Exception $e) {
$values['editTo'] = '';
unset($values['timeTo']);
}
}
return $values;
}
/**
* Find milestones that contain a specific term in their headline.
*
* @param string $term The term to search for in the headline.
* @param int $projectId The ID of the project to search milestones in.
* @return array The array of milestones that match the search term.
*
* @api
*/
#[RequiresPermission(TicketsPermissions::VIEW, projectIdParam: 'projectId')]
public function findMilestone(string $term, int $projectId)
{
$milestones = $this->getAllMilestones(['currentProject' => $projectId]);
foreach ($milestones as $key => $milestone) {
if (Str::contains($milestones[$key]['headline'], $term, ignoreCase: true)) {
$milestones[$key] = $this->prepareDatesForApiResponse($milestone);
} else {
unset($milestones[$key]);
}
}
return $milestones;
}
/**
* Finds tickets based on search term, project ID, and optional user ID.
*
* @param string $term The search term to match against ticket headlines.
* @param int $projectId The ID of the project to search within.
* @param int|null $userId (Optional) The ID of the user to limit the search to.
* @return array An array of tickets matching the search criteria.
*
* @api
*/
#[RequiresPermission(TicketsPermissions::VIEW, projectIdParam: 'projectId')]
public function findTicket(string $term, int $projectId, ?int $userId)
{
$milestones = $this->getAll([
'currentProject' => $projectId,
'term' => $term,
'users' => $userId,
]);
foreach ($milestones as $key => $milestone) {
$milestones[$key] = $this->prepareDatesForApiResponse($milestone);
}
return $milestones;
}
/**
* Retrieve milestones for a specific project and user.
*
* @param int|null $projectId The ID of the project (optional)
* @param int|null $userId The ID of the user (optional)
* @return array|false An array of milestones or false if an error occurred
*
* @api
*/
#[RequiresPermission(TicketsPermissions::VIEW, projectIdParam: 'projectId')]
public function pollForNewAccountMilestones(?int $projectId = null, ?int $userId = null): array|false
{
$todos = $this->ticketRepository->getAllBySearchCriteria(
[
'type' => 'milestone',
'currentProject' => $projectId,
'users' => $userId,
],
'date'
);
foreach ($todos as $key => $todo) {
$todos[$key] = $this->prepareDatesForApiResponse($todo);
}
return $todos;
}
/**
* Polls for updated account milestones.
*
* Retrieves all milestones based on the provided search criteria and prepares the dates for API response.
*
* @param int|null $projectId (optional) The ID of the project to filter milestones by.
* @param int|null $userId (optional) The ID of the user to filter milestones by.
* @return array|false An array of milestones with prepared dates for API response, or false if an error occurs.
*
* @api
*/
#[RequiresPermission(TicketsPermissions::VIEW, projectIdParam: 'projectId')]
public function pollForUpdatedAccountMilestones(?int $projectId = null, ?int $userId = null): array|false
{
$milestones = $this->ticketRepository->getAllBySearchCriteria(
[
'type' => 'milestone',
'currentProject' => $projectId,
'users' => $userId,
],
'date'
);
foreach ($milestones as $key => $milestone) {
$milestones[$key] = $this->prepareDatesForApiResponse($milestone);
$milestones[$key]['id'] = $milestone['id'].'-'.$milestone['date'];
}
return $milestones;
}
/**
* Polls for new account todos.
*
* Retrieves all account todos based on the provided search criteria. If no criteria are provided,
* it will return all todos. Optionally, a project ID and a user ID can be specified to filter the todos.
* It excludes todos of type "milestone".
*
* @param int|null $projectId The ID of the project to filter the todos (optional).
* @param int|null $userId The ID of the user to filter the todos (optional).
* @return array|false The retrieved todos as an array of associative arrays.
* Returns false if an error occurs during retrieval.
*
* @api
*/
#[RequiresPermission(TicketsPermissions::VIEW, projectIdParam: 'projectId')]
public function pollForNewAccountTodos(?int $projectId = null, ?int $userId = null): array|false
{
$todos = $this->ticketRepository->getAllBySearchCriteria(
[
'excludeType' => 'milestone',
'currentProject' => $projectId,
'users' => $userId,
],
'date'
);
foreach ($todos as $key => $todo) {
$todos[$key] = $this->prepareDatesForApiResponse($todo);
}
return $todos;
}
/**
* Polls for updated account todos.
*
* @param int|null $projectId The ID of the project (optional)
* @param int|null $userId The ID of the user (optional)
* @return array|false An array of updated account todos or false if there was an error
*
* @api
*/
#[RequiresPermission(TicketsPermissions::VIEW, projectIdParam: 'projectId')]
public function pollForUpdatedAccountTodos(?int $projectId = null, ?int $userId = null): array|false
{
$todos = $this->ticketRepository->getAllBySearchCriteria(
[
'excludeType' => 'milestone',
'currentProject' => $projectId,
'users' => $userId,
],
'date'
);
foreach ($todos as $key => $todo) {
$todos[$key] = $this->prepareDatesForApiResponse($todo);
$todos[$key]['id'] = $todo['id'].'-'.$todo['date'];
}
return $todos;
}
private function prepareDatesForApiResponse($todo)
{
if (dtHelper()->isValidDateString($todo['date'])) {
$todo['date'] = dtHelper()->parseDbDateTime($todo['date'])->toIso8601ZuluString();
} else {
$todo['date'] = null;
}
if (dtHelper()->isValidDateString($todo['dateToFinish'])) {
$todo['dateToFinish'] = dtHelper()->parseDbDateTime($todo['dateToFinish'])->toIso8601ZuluString();
} else {
$todo['dateToFinish'] = null;
}
if (dtHelper()->isValidDateString($todo['editFrom'])) {
$todo['editFrom'] = dtHelper()->parseDbDateTime($todo['editFrom'])->toIso8601ZuluString();
} else {
$todo['editFrom'] = null;
}
if (dtHelper()->isValidDateString($todo['editTo'])) {
$todo['editTo'] = dtHelper()->parseDbDateTime($todo['editTo'])->toIso8601ZuluString();
} else {
$todo['editTo'] = null;
}
return $todo;
}
}