authorize()/$this->can() in-body, or authorization recurses * infinitely. They stay ungated (pure repo / role reads). */ class Projects extends BaseService implements ChecksProjectAccess { /** * Request-scoped memo for getProjectsAssignedToUser(), keyed by * "userId|status|clientId|projectTypes". * * @var array */ private array $assignedProjectsMemo = []; private Client $httpClient; public function __construct( private ProjectRepository $projectRepository, private TicketRepository $ticketRepository, private SettingRepository $settingsRepo, private LanguageCore $language, private Messengers $messengerService, private NotificationService $notificationService, protected Files $fileService, protected Avatarcreator $avatarcreator, private QueueRepository $queueRepo, private UserRepository $userRepo, private CommentRepository $commentRepo, private ClientRepository $clientRepo, Client $httpClient, ) { $this->httpClient = $httpClient; } /** * Gets the project types. * * * @api */ public function getProjectTypes(): mixed { $types = ['project' => 'label.project']; $filtered = static::dispatch_filter('filterProjectType', $types); // Strategy & Program are protected types if (isset($filtered['strategy'])) { unset($filtered['strategy']); } if (isset($filtered['program'])) { unset($filtered['program']); } return $filtered; } /** * Gets the project with the given ID. * * @param int $id The ID of the project to retrieve. * @return bool|array Returns the project data as an associative array if the project exists, otherwise returns false. * * @api */ #[RequiresPermission(ProjectsPermissions::VIEW, projectIdParam: 'id')] public function getProject(int $id): bool|array { return $this->projectRepository->getProject($id); } // Gets project progress /** * Gets the progress of a project. * Calculates the completion percentage, estimated completion date, * and planned completion date of the project. * * @param int $projectId The ID of the project. * @return array The progress of the project. * * @api */ #[RequiresPermission(ProjectsPermissions::VIEW, projectIdParam: 'projectId')] public function getProjectProgress($projectId): array { // Same shape as the computed return below — including // `estimatedCompletionState`. Without it the no-data early return // let templates fall back to the default 'ready' state and render // as though an estimate existed. Copy is i18n'd to match. $returnValue = [ 'percent' => 0, 'estimatedCompletionDate' => $this->language->__('label.complete_more_todos'), 'estimatedCompletionState' => 'needs_more_data', 'plannedCompletionDate' => '', ]; $averageStorySize = $this->ticketRepository->getAverageTodoSize($projectId); // We'll use this as the start date of the project $firstTicket = $this->ticketRepository->getFirstTicket($projectId); if (is_object($firstTicket) === false) { return $returnValue; } $dateOfFirstTicket = new DateTime($firstTicket->date); $today = new DateTime; $totalprojectDays = (int) $today->diff($dateOfFirstTicket)->format('%a'); // Calculate percent // One query for all four counts/efforts instead of four separate table scans. $aggregates = $this->ticketRepository->getProjectProgressAggregates($projectId, $averageStorySize); $numberOfClosedTickets = $aggregates['closedCount']; $numberOfTotalTickets = $aggregates['allCount']; if ($numberOfTotalTickets == 0) { $percentNum = 0; } else { $percentNum = ($numberOfClosedTickets / $numberOfTotalTickets) * 100; } $effortOfClosedTickets = $aggregates['closedEffort']; $effortOfTotalTickets = $aggregates['allEffort']; if ($effortOfTotalTickets == 0) { $percentEffort = $percentNum; // This needs to be set to percentNum in case users choose to not use efforts } else { $percentEffort = ($effortOfClosedTickets / $effortOfTotalTickets) * 100; } $finalPercent = $percentEffort; if ($totalprojectDays > 0) { $dailyPercent = $finalPercent / $totalprojectDays; } else { $dailyPercent = 0; } $percentLeft = 100 - $finalPercent; if ($dailyPercent == 0) { $estDaysLeftInProject = 10000; } else { $estDaysLeftInProject = ceil($percentLeft / $dailyPercent); } $today->add(new DateInterval('P'.$estDaysLeftInProject.'D')); // Fix this $currentDate = new DateTime; $inFiveYears = intval($currentDate->format('Y')) + 5; if (intval($today->format('Y')) >= $inFiveYears) { $completionDate = 'Past '.$inFiveYears; } else { $completionDate = $today->format($this->language->__('language.dateformat')); } // Return shape carries a plain-text status and a machine-readable // state — templates branch on the state to render the appropriate // CTA (e.g. a "showAll" link) instead of embedding presentation // HTML in the string. Non-template callers (MCP tools, JSON-RPC) // just read the plain text as-is. $returnValue = [ 'percent' => $finalPercent, 'estimatedCompletionDate' => $completionDate, 'estimatedCompletionState' => 'ready', 'plannedCompletionDate' => '', ]; if ($numberOfClosedTickets < 10) { $returnValue['estimatedCompletionState'] = 'needs_more_data'; $returnValue['estimatedCompletionDate'] = $this->language->__('label.complete_more_todos'); } elseif ($finalPercent == 100) { $returnValue['estimatedCompletionState'] = 'complete'; $returnValue['estimatedCompletionDate'] = $this->language->__('label.project_complete_onto_next'); } return $returnValue; } /** * Gets an array of user IDs to notify for a given project. * * @param int $projectId The ID of the project to get users to notify for. * @return array An array of user IDs. * * @api */ #[RequiresPermission(ProjectsPermissions::VIEW, projectIdParam: 'projectId')] public function getUsersToNotify($projectId): array { $users = $this->projectRepository->getUsersAssignedToProject($projectId); $to = []; // Only users that actually want to be notified and are active foreach ($users as $user) { if ($user['notifications'] != 0 && strtolower($user['status']) == 'a') { $to[] = $user['id']; } } return $to; } /** * Gets all the users who need to be notified for a given project. * * @param int $projectId The ID of the project. * @return array An array of users to notify. * * @api */ #[RequiresPermission(ProjectsPermissions::VIEW, projectIdParam: 'projectId')] public function getAllUserInfoToNotify($projectId): array { $users = $this->projectRepository->getUsersAssignedToProject($projectId); $to = []; // Only users that actually want to be notified foreach ($users as $user) { if ($user['notifications'] != 0 && ($user['username'] != session('userdata.mail'))) { $to[] = $user; } } return $to; } // TODO Split and move to notifications /** * Notifies the users associated with a project about a notification. * * Applies two-layer filtering before sending: * 1. Per-project mute — users who muted this project are excluded * 2. Event-type preference — users who disabled this event category are excluded * * @mentions always bypass both layers. * * @param Notification $notification The notification object to send. * * @api */ public function notifyProjectUsers(Notification $notification): void { // Filter notifications (dispatch_filter returns mixed; the filter preserves the entity) /** @var Notification $notification */ $notification = EventCore::dispatch_filter('notificationFilter', $notification); // Email $users = $this->getUsersToNotify($notification->projectId); $projectName = $this->getProjectName($notification->projectId); // Exclude the author $users = array_filter($users, function ($user) use ($notification) { return $user != $notification->authorId; }, ARRAY_FILTER_USE_BOTH); $users = array_values($users); // Batch-load notification preferences for all candidate users $settingKeys = []; foreach ($users as $userId) { $settingKeys[] = 'usersettings.'.$userId.'.projectNotificationLevels'; $settingKeys[] = 'usersettings.'.$userId.'.projectMutedNotifications'; // legacy format $settingKeys[] = 'usersettings.'.$userId.'.notificationEventTypes'; } $settingKeys[] = 'companysettings.defaultNotificationEventTypes'; $settingKeys[] = 'companysettings.defaultNotificationRelevance'; $preloadedSettings = $this->settingsRepo->getSettingsForKeys($settingKeys); // Layer 1: Filter by per-project relevance level (all / my_work / muted) $users = $this->filterUsersByProjectRelevance($users, $notification, $preloadedSettings); // Layer 2: Remove users who disabled this event type category $users = $this->filterUsersByEventType($users, $notification->module, $preloadedSettings); // Mentions and collaborators both bypass the two filter layers above. $mentionedUserIds = $this->extractMentionedUserIds($notification); $collaboratorIds = $this->extractCollaboratorIds($notification); $users = $this->addBypassRecipients($users, $mentionedUserIds, $notification->authorId); $users = $this->addBypassRecipients($users, $collaboratorIds, $notification->authorId); $emailMessage = $notification->message; if ($notification->url !== false) { $emailMessage .= " ".$notification->url['text'].''; } // NEW Queuing messaging system $queue = app()->make(QueueRepository::class); $queue->queueMessageToUsers($users, $emailMessage, $notification->subject, $notification->projectId); // Send to messengers $this->messengerService->sendNotificationToMessengers($notification, $projectName); // Send mobile push notifications to recipients with a registered // device token. No-op for users with no mobile token; no-op for // FCM-provider rows when LEAN_PUSH_FCM_CREDENTIALS_PATH / // LEAN_PUSH_FCM_PROJECT_ID aren't configured. Wrapped in try // so a push outage never breaks the rest of the notification // dispatch path (queued emails + messengers still fire). try { $pushService = app()->make(\Leantime\Domain\Notifications\Services\Push::class); $pushService->sendFromNotification($notification, $users); } catch (\Throwable $e) { \Illuminate\Support\Facades\Log::warning('Push dispatch failed: '.$e->getMessage()); } // Notify users about mentions // Fields that should be parsed for mentions $mentionFields = [ 'comments' => ['text'], 'projects' => ['details'], 'tickets' => ['description'], 'canvas' => ['description', 'data', 'conclusion', 'assumptions'], ]; $contentToCheck = ''; // Find entity ID & content // Todo once all entities are models this if statement can be reduced if (isset($notification->entity) && is_array($notification->entity) && isset($notification->entity['id'])) { $entityId = $notification->entity['id']; if (isset($mentionFields[$notification->module])) { $fields = $mentionFields[$notification->module]; foreach ($fields as $field) { if (isset($notification->entity[$field])) { $contentToCheck .= $notification->entity[$field]; } } } } elseif (isset($notification->entity) && is_object($notification->entity) && isset($notification->entity->id)) { $entityId = $notification->entity->id; if (isset($mentionFields[$notification->module])) { $fields = $mentionFields[$notification->module]; foreach ($fields as $field) { if (isset($notification->entity->$field)) { $contentToCheck .= $notification->entity->$field; } } } } else { // Entity id not set use project id $entityId = $notification->projectId; } if ($contentToCheck != '') { $this->notificationService->processMentions( $contentToCheck, $notification->module, (int) $entityId, $notification->authorId, $notification->url['url'] ); } // Apply same two-layer filtering to in-app notification users $allUsersToNotify = $this->getAllUserInfoToNotify($notification->projectId); $allUserIds = array_map(fn ($u) => $u['id'], $allUsersToNotify); $filteredIds = $this->filterUsersByProjectRelevance($allUserIds, $notification, $preloadedSettings); $filteredIds = $this->filterUsersByEventType($filteredIds, $notification->module, $preloadedSettings); // Re-add mentions and collaborators for in-app notifications too $filteredIds = $this->addBypassRecipients($filteredIds, $mentionedUserIds, $notification->authorId); $filteredIds = $this->addBypassRecipients($filteredIds, $collaboratorIds, $notification->authorId); $filteredUsersToNotify = array_filter($allUsersToNotify, fn ($u) => in_array($u['id'], $filteredIds)); /** * This event is fired to notify project users of important updates. * An event "notifyProjectUsers" is dispatched with an array of variables required for the notification. * These variables include the type of update, module affected, entity ID, message and subject of notification, * users to be notified, and url if present. This event belongs to the "domain.services.projects" context. * * @event notifyProjectUsers * * @param string $type The type of update. E.g., "projectUpdate" * @param string $module The name of the module affected by the update. * @param int $moduleId The ID of the entity affected by the update. * @param string $message The content of the notification message. * @param string $subject The subject of the notification message. * @param array $users The users to be notified about this update (filtered by notification preferences). * @param string|null $url The url leading to the update if any. * * @context domain.services.projects */ self::dispatch_event('notifyProjectUsers', ['type' => 'projectUpdate', 'module' => $notification->module, 'moduleId' => $entityId, 'message' => $notification->message, 'subject' => $notification->subject, 'users' => array_values($filteredUsersToNotify), 'url' => $notification->url['url']], 'leantime.domain.projects.services.projects.notifyProjectUsers'); } /** * Filters users by their per-project notification relevance level. * * Supports three levels: * - 'all': User receives all notifications from this project (default). * - 'my_work': User only receives notifications for items they are assigned to, * created, or are directly involved in. * - 'muted': User receives no notifications from this project. * * Performs lazy migration from the old binary mute format (projectMutedNotifications) * to the new three-level format (projectNotificationLevels). * * @param array $userIds User IDs to filter. * @param Notification $notification The notification being dispatched. * @param array $preloadedSettings Pre-fetched settings map. * @return array Filtered user IDs. */ private function filterUsersByProjectRelevance(array $userIds, Notification $notification, array $preloadedSettings): array { $projectId = $notification->projectId; $companyDefault = $preloadedSettings['companysettings.defaultNotificationRelevance'] ?? Notification::RELEVANCE_ALL; if (! Notification::isValidRelevanceLevel($companyDefault)) { $companyDefault = Notification::RELEVANCE_ALL; } return array_values(array_filter($userIds, function (int $userId) use ($projectId, $notification, $preloadedSettings, $companyDefault) { $level = $this->getProjectRelevanceLevel($userId, $projectId, $preloadedSettings, $companyDefault); if ($level === Notification::RELEVANCE_MUTED) { return false; } if ($level === Notification::RELEVANCE_MY_WORK) { return $this->isUserInvolvedInNotification($userId, $notification); } // RELEVANCE_ALL: keep the user return true; })); } /** * Determines the notification relevance level for a user on a specific project. * * Checks the new projectNotificationLevels format first, falls back to * the legacy projectMutedNotifications format, then to company default. * * @param int $userId The user ID. * @param int $projectId The project ID. * @param array $preloadedSettings Pre-fetched settings map. * @param string $companyDefault The company-level default relevance. * @return string The relevance level constant. */ private function getProjectRelevanceLevel(int $userId, int $projectId, array $preloadedSettings, string $companyDefault): string { // Check new format first $newKey = 'usersettings.'.$userId.'.projectNotificationLevels'; $newSetting = $preloadedSettings[$newKey] ?? false; if (! empty($newSetting) && $newSetting !== false) { $levels = json_decode($newSetting, true); if (is_array($levels) && isset($levels[$projectId])) { $level = $levels[$projectId]; if (Notification::isValidRelevanceLevel($level)) { return $level; } } } // Lazy migration: check old muted-projects format $oldKey = 'usersettings.'.$userId.'.projectMutedNotifications'; $oldSetting = $preloadedSettings[$oldKey] ?? false; if (! empty($oldSetting) && $oldSetting !== false) { $mutedIds = json_decode($oldSetting, true); if (is_array($mutedIds) && in_array($projectId, $mutedIds)) { return Notification::RELEVANCE_MUTED; } } return $companyDefault; } /** * Checks whether a user is directly involved in the entity being notified about. * * A user is considered "involved" if they are the assignee (editorId), * the creator (userId), or otherwise linked to the entity. * * @param int $userId The user to check. * @param Notification $notification The notification with entity data. * @return bool True if the user is involved. */ private function isUserInvolvedInNotification(int $userId, Notification $notification): bool { $entity = $notification->entity; if (is_array($entity)) { // Ticket/item: editorId is the assignee, userId is the creator if (isset($entity['editorId']) && (int) $entity['editorId'] === $userId) { return true; } if (isset($entity['collaborators']) && is_array($entity['collaborators']) && in_array($userId, array_map('intval', $entity['collaborators']), true)) { return true; } if (isset($entity['userId']) && (int) $entity['userId'] === $userId) { return true; } // Canvas items: author field if (isset($entity['author']) && (int) $entity['author'] === $userId) { return true; } } elseif (is_object($entity)) { if (isset($entity->editorId) && (int) $entity->editorId === $userId) { return true; } if (isset($entity->collaborators) && is_array($entity->collaborators) && in_array($userId, array_map('intval', $entity->collaborators), true)) { return true; } if (isset($entity->userId) && (int) $entity->userId === $userId) { return true; } if (isset($entity->author) && (int) $entity->author === $userId) { return true; } } return false; } /** * Filters out users who have disabled the notification category for a given module. * * Falls back to company defaults if the user has no personal preference, and treats * missing company defaults as all-enabled. * * @param array $userIds User IDs to filter. * @param string $module The notification module value (e.g. 'tickets', 'comments'). * @param array $preloadedSettings Pre-fetched settings map. * @return array Filtered user IDs. */ private function filterUsersByEventType(array $userIds, string $module, array $preloadedSettings): array { $category = Notification::getCategoryForModule($module); // Unknown module category — let notification through if ($category === null) { return $userIds; } $companyDefault = $preloadedSettings['companysettings.defaultNotificationEventTypes'] ?? false; $companyEnabledTypes = null; if (! empty($companyDefault) && $companyDefault !== false) { $companyEnabledTypes = json_decode($companyDefault, true); if (! is_array($companyEnabledTypes)) { $companyEnabledTypes = null; } } return array_values(array_filter($userIds, function (int $userId) use ($category, $preloadedSettings, $companyEnabledTypes) { $key = 'usersettings.'.$userId.'.notificationEventTypes'; $setting = $preloadedSettings[$key] ?? false; if (! empty($setting) && $setting !== false) { $enabledTypes = json_decode($setting, true); if (is_array($enabledTypes)) { return in_array($category, $enabledTypes); } } // Fall back to company default if ($companyEnabledTypes !== null) { return in_array($category, $companyEnabledTypes); } // No preferences set anywhere — all enabled return true; })); } /** * Gets the count of users who have muted or reduced notifications for a project. * * Checks both new (projectNotificationLevels) and legacy (projectMutedNotifications) formats. * * @param int $projectId The project ID. * @return int The number of users who have muted this project. */ /** * Extracts user IDs mentioned in the notification entity content. * * @param Notification $notification The notification to scan for mentions. * @return array Array of mentioned user IDs. */ private function extractMentionedUserIds(Notification $notification): array { $mentionFields = [ 'comments' => ['text'], 'projects' => ['details'], 'tickets' => ['description'], 'canvas' => ['description', 'data', 'conclusion', 'assumptions'], ]; $contentToCheck = ''; if (isset($notification->entity) && is_array($notification->entity)) { if (isset($mentionFields[$notification->module])) { foreach ($mentionFields[$notification->module] as $field) { if (isset($notification->entity[$field])) { $contentToCheck .= $notification->entity[$field]; } } } } elseif (isset($notification->entity) && is_object($notification->entity)) { if (isset($mentionFields[$notification->module])) { foreach ($mentionFields[$notification->module] as $field) { if (isset($notification->entity->$field)) { $contentToCheck .= $notification->entity->$field; } } } } if (empty($contentToCheck)) { return []; } $userIds = []; $dom = new \DOMDocument; @$dom->loadHTML($contentToCheck); $links = $dom->getElementsByTagName('a'); for ($i = 0; $i < $links->count(); $i++) { $taggedUser = $links->item($i)->getAttribute('data-tagged-user-id'); if ($taggedUser !== '' && is_numeric($taggedUser)) { $userIds[] = (int) $taggedUser; } } return array_unique($userIds); } /** * Extracts collaborator user IDs from a ticket notification entity. * * Collaborators bypass notification filters so they always receive * updates for tickets they are collaborating on. * * @param Notification $notification The notification to extract collaborators from. * @return array An array of unique user IDs who are collaborators. */ private function extractCollaboratorIds(Notification $notification): array { if ($notification->module !== 'tickets') { return []; } $collaborators = []; if (isset($notification->entity) && is_array($notification->entity)) { $collaborators = $notification->entity['collaborators'] ?? []; } elseif (isset($notification->entity) && is_object($notification->entity)) { $collaborators = $notification->entity->collaborators ?? []; } if (empty($collaborators) || ! is_array($collaborators)) { return []; } // Only keep scalar, numeric values so non-scalars can't become bogus IDs (e.g. intval([]) === 0). $scalarNumeric = array_filter($collaborators, fn ($c) => is_scalar($c) && is_numeric($c)); $ids = array_filter(array_map('intval', $scalarNumeric), fn ($id) => $id > 0); return array_values(array_unique($ids)); } /** * Adds bypass recipients (e.g. mentions, collaborators) to a recipient list. * * Bypass recipients skip the two notification filter layers, so they are * appended after filtering. The notification author is never added, and * existing recipients are not duplicated. * * @param array $recipients The current recipient user IDs. * @param array $bypassUserIds User IDs that should always receive the notification. * @param mixed $authorId The notification author, excluded from the result. * @return array The recipient list with bypass users merged in. */ private function addBypassRecipients(array $recipients, array $bypassUserIds, mixed $authorId): array { foreach ($bypassUserIds as $bypassId) { if ($bypassId != $authorId && ! in_array($bypassId, $recipients)) { $recipients[] = $bypassId; } } return $recipients; } /** * Gets the count of users who have muted notifications for a specific project. * * @param int $projectId The project ID. * @return int Number of users who have muted this project. * * @api */ #[RequiresPermission(ProjectsPermissions::VIEW, projectIdParam: 'projectId')] public function getMuteCountForProject(int $projectId): int { $db = app()->make(\Illuminate\Database\ConnectionInterface::class); $count = 0; // Check new format: projectNotificationLevels $newRows = $db->table('zp_settings') ->where('key', 'LIKE', 'usersettings.%.projectNotificationLevels') ->get(['value']); foreach ($newRows as $row) { $levels = json_decode($row->value, true); if (is_array($levels) && isset($levels[$projectId]) && $levels[$projectId] === Notification::RELEVANCE_MUTED) { $count++; } } // Also check legacy format: projectMutedNotifications $oldRows = $db->table('zp_settings') ->where('key', 'LIKE', 'usersettings.%.projectMutedNotifications') ->get(['value']); foreach ($oldRows as $row) { $mutedProjects = json_decode($row->value, true); if (is_array($mutedProjects) && in_array($projectId, $mutedProjects)) { $count++; } } return $count; } /** * Retrieves the name of a project based on its ID. * * @param int $projectId The ID of the project. * @return string|null The name of the project, or null if the project does not exist. * * @api */ #[RequiresPermission(ProjectsPermissions::VIEW, projectIdParam: 'projectId')] public function getProjectName($projectId) { $project = $this->projectRepository->getProject($projectId); if ($project) { return $project['name']; } return null; } /** * Resolves the userId for an "assigned-to-user" read to the SESSION user unless the caller is an * admin/owner querying someone else — closing the cross-user param spoof on these @api reads (an * RPC caller could otherwise list another user's projects by passing a foreign id). Mirrors the * inline override in getProjectsUserHasAccessTo(). Recursion-safe: a global role check only, * never project membership. * * @param int|string|null $userId */ private function resolveScopedUserId($userId): int { $sessionUser = (int) session('userdata.id'); if ((int) $userId !== $sessionUser && ! Auth::userIsAtLeast(Roles::$admin)) { return $sessionUser; } return (int) $userId; } /** * Gets the project IDs assigned to a specified user. * * @param int $userId The ID of the user. * @return false|array The project IDs assigned to the user, or false if no projects are found. * * @api */ public function getProjectIdAssignedToUser($userId): false|array { $userId = $this->resolveScopedUserId($userId); $projects = $this->projectRepository->getUserProjectRelation($userId); if ($projects) { return $projects; } else { return false; } } /** * Gets projects assigned to a user. * * @param int $userId The ID of the user. * @param string $projectStatus The status of the projects. Defaults to "open". * @param int|null $clientId The ID of the client. Defaults to null. * @return array The projects assigned to the user. * * @api */ public function getProjectsAssignedToUser($userId, string $projectStatus = 'open', $clientId = null, string $projectTypes = 'all'): array { $userId = $this->resolveScopedUserId($userId); // Request-scoped memo: this 11-join query is hit several times per page // load (status labels, multiple dashboard widgets). A user's project // assignments don't change within a request, so memoizing is safe. $memoKey = $userId.'|'.$projectStatus.'|'.($clientId ?? '').'|'.$projectTypes; if (isset($this->assignedProjectsMemo[$memoKey])) { return $this->assignedProjectsMemo[$memoKey]; } $projects = $this->projectRepository->getUserProjects(userId: $userId, projectStatus: $projectStatus, clientId: $clientId, projectTypes: $projectTypes); return $this->assignedProjectsMemo[$memoKey] = $projects ?: []; } /** * Finds all children projects for a given parent project. * * @param mixed $currentParentId The ID of the current parent project. * @param array $projects An array of projects to search for children. * @return array An array of children projects found. * * @api */ public function findMyChildren($currentParentId, array $projects): array { $childrenByParent = []; foreach ($projects as $project) { $childrenByParent[$project['parent'] ?? 0][] = $project; } return $this->buildProjectBranch($currentParentId, $childrenByParent, []); } /** * Assembles one branch of the project tree from a parentId => children map. * * The visited set guards against self-referential or cyclic parent data — * without it a project whose parent chain loops back on itself recurses * until memory is exhausted (rendered on every page via the project selector). * * @param mixed $parentId The parent project ID to collect children for. * @param array $childrenByParent Projects grouped by their parent ID. * @param array $visited Project IDs already on this branch's path. * @return array The assembled branch. */ private function buildProjectBranch($parentId, array $childrenByParent, array $visited): array { $branch = []; foreach ($childrenByParent[$parentId] ?? [] as $project) { if (isset($visited[$project['id']])) { continue; } $visited[$project['id']] = true; $children = $this->buildProjectBranch($project['id'], $childrenByParent, $visited); if ($children) { $project['children'] = $children; } $branch[] = $project; } return $branch; } /** * Cleans the parent relationship in the given array of projects. * Removes projects that have a parent project that does not exist in the array. * Assigns a parent id of 0 to projects that have no parent. * * @param array $projects An array of projects * @return array The cleaned array of projects * * @api */ public function cleanParentRelationship(array $projects): array { $parentIds = []; foreach ($projects as $project) { $parentIds[$project['id']] = $project['parent']; } $cleanList = []; foreach ($projects as $project) { // Use array_key_exists, not isset: a top-level strategy/program has parent = NULL, // and isset() reports false for a NULL value. isset() would therefore treat every // child of a top-level parent as "parent missing" and re-root it to 0, dropping it // out of its strategy group in the Projects dropdown (#3617). if (! array_key_exists($project['parent'], $parentIds) || $this->parentChainLoops($project['id'], $parentIds)) { $project['parent'] = 0; } $cleanList[] = $project; } return $cleanList; } /** * Checks whether a project's parent chain loops back on itself * (self-parent or a longer cycle like A → B → A) within the given set. * * @param mixed $projectId The project ID whose ancestry to walk. * @param array $parentIds Map of project ID => parent ID. * @return bool True when the chain revisits a project (cycle). */ private function parentChainLoops($projectId, array $parentIds): bool { $seen = []; $current = $parentIds[$projectId] ?? 0; while (! empty($current) && isset($parentIds[$current])) { if ($current == $projectId) { return true; } // An ancestor further up is cyclic, but this project isn't part of the // loop itself — the cycle members get re-rooted, so this link stays valid. if (isset($seen[$current])) { return false; } $seen[$current] = true; $current = $parentIds[$current]; } return false; } /** * Gets the hierarchy of projects assigned to a user. * * @param int $userId The ID of the user. * @param string $projectStatus The project status. Default is "open". * @param int|null $clientId The ID of the client. Default is null. * @return array An array containing the assigned projects, the project hierarchy, and the favorite projects. * * @api */ public function getProjectHierarchyAssignedToUser($userId, string $projectStatus = 'open', $clientId = null): array { $userId = $this->resolveScopedUserId($userId); // Load all projects user is assigned to $projects = $this->projectRepository->getUserProjects( userId: $userId, projectStatus: $projectStatus, clientId: (int) $clientId, accessStatus: 'assigned' ); $projects = self::dispatch_filter('afterLoadingProjects', $projects); // Build project hierarchy $projectsClean = $this->cleanParentRelationship($projects); $projectHierarchy = $this->findMyChildren(0, $projectsClean); $projectHierarchy = self::dispatch_filter('afterPopulatingProjectHierarchy', $projectHierarchy, ['projects' => $projects]); // Get favorite projects $favorites = []; foreach ($projects as $project) { if (isset($project['isFavorite']) && $project['isFavorite'] == 1) { $favorites[] = $project; } } $favorites = self::dispatch_filter('afterPopulatingProjectFavorites', $favorites, ['projects' => $projects]); return [ 'allAssignedProjects' => $projects, 'allAssignedProjectsHierarchy' => $projectHierarchy, 'favoriteProjects' => $favorites, ]; } /** * Gets the project hierarchy available to a user. * * @param int $userId The ID of the user. * @param string $projectStatus The status of the projects to retrieve. Defaults to "open". * @param int|null $clientId The ID of the client. Defaults to null. * @return array Returns an array containing the following keys: * - "allAvailableProjects": An array of all projects available to the user. * - "allAvailableProjectsHierarchy": An array representing the project hierarchy available to the user. * - "clients": An array of clients associated with the projects available to the user. * * @api */ public function getProjectHierarchyAvailableToUser($userId, string $projectStatus = 'open', $clientId = null): array { $userId = $this->resolveScopedUserId($userId); // Load all projects user is assigned to $projects = $this->projectRepository->getProjectsUserHasAccessTo( userId: $userId, status: $projectStatus, clientId: (int) $clientId, ); $projects = self::dispatch_filter('afterLoadingProjects', $projects); // Build project hierarchy $projectsClean = $this->cleanParentRelationship($projects); $projectHierarchy = $this->findMyChildren(0, $projectsClean); $projectHierarchy = self::dispatch_filter('afterPopulatingProjectHierarchy', $projectHierarchy, ['projects' => $projects]); $clients = $this->getClientsFromProjectList($projects); return [ 'allAvailableProjects' => $projects, 'allAvailableProjectsHierarchy' => $projectHierarchy, 'clients' => $clients, ]; } /** * Gets all the clients available to a user. * * @param int $userId The ID of the user. * @param string $projectStatus The status of the projects to be considered. Defaults to "open". * @return array An array of clients available to the user. * * @api */ public function getAllClientsAvailableToUser($userId, string $projectStatus = 'open'): array { $userId = $this->resolveScopedUserId($userId); // Load all projects user is assigned to $projects = $this->projectRepository->getUserProjects( userId: $userId, projectStatus: $projectStatus, clientId: null, accessStatus: 'all' ); $projects = self::dispatch_filter('afterLoadingProjects', $projects); $clients = $this->getClientsFromProjectList($projects); return $clients; } public function getClientsFromProjectList(array $projects): array { $clients = []; foreach ($projects as $project) { if (! array_key_exists($project['clientId'], $clients)) { $clients[$project['clientId']] = [ 'name' => $project['clientName'], 'id' => $project['clientId'], ]; } } return $clients; } /** * Gets the role of a user in a specific project. * * @param int $userId The user ID. * @param int $projectId The project ID. * @return string The stored project-role key, or an empty string when the user has no * explicit role in the project (or is not assigned to it). * * @api */ public function getProjectRole($userId, $projectId): string { $projectRole = $this->projectRepository->getUserProjectRelation($userId, $projectId)[0]['projectRole'] ?? ''; if ($projectRole === '') { return ''; } // For a numeric role key, only return it when it's a real, non-admin/owner role. The legacy // "0" (written before "inherit" was handled on save) and any other unknown/junk key resolve // to "no explicit role" so callers safely fall back to the user's global role instead of an // unresolvable one that would deny all project access. if (ctype_digit((string) $projectRole)) { $roles = Roles::getRoles(); $assignableRoles = array_diff_key($roles, [ array_search(Roles::$admin, $roles, true) => null, array_search(Roles::$owner, $roles, true) => null, ]); return isset($assignableRoles[(int) $projectRole]) ? (string) (int) $projectRole : ''; } // "inherit"/"inherited" are legacy sentinels (the pre-numeric "Inherit" access level) that // mean "no explicit role" -> normalize to '' so callers fall back to the user's global role. // Without this, RoleResolver casts the string to (int) 0, Roles::getRoleString(0) returns // false, and a genuine project member gets a 403 (#3618). Any other stored value (a numeric // key handled above, or a legacy role name) is returned unchanged so real per-project roles // are preserved. if (in_array(strtolower((string) $projectRole), ['inherit', 'inherited'], true)) { return ''; } return (string) $projectRole; } /** * Gets the projects that a user has access to. * * The $userId parameter is preserved for backwards compatibility with * existing positional callers (web controllers like ShowTicket::run, * NewTicket::run, and any plugin/custom code). Default is null so RPC * clients (mobile) can call without it and have the session user * resolved server-side. * * Role-based authorization on $userId, per @marcelfolaron review: * - Not passed (null/0): use the authenticated session user * - Passed AND caller is admin/owner: honor the requested user id * (admin tooling, "show me Alice's projects" workflows) * - Passed by a non-admin AND differs from session: ignored and * overridden to the session user (prevents IDOR via the optional * param) * * @param int|null $userId Optional. If omitted or 0, resolves to * the authenticated session user. * @return array|false The array of projects if the user has access, false otherwise. * * @api */ public function getProjectsUserHasAccessTo(?int $userId = null): false|array { $sessionUser = (int) session('userdata.id'); if ($userId === null || $userId === 0) { $userId = $sessionUser; } elseif ($userId !== $sessionUser && ! Auth::userIsAtLeast(Roles::$admin)) { $userId = $sessionUser; } if ($userId === 0) { return false; } $projects = $this->projectRepository->getUserProjects(userId: $userId, accessStatus: 'all'); if ($projects) { return $projects; } else { return false; } } /** * @api * * Returns the user's accessible projects ordered by the user's OWN * most-recent activity in each — specifically, the most recent * `zp_tickets.modified` timestamp where the user is the ticket's * editor or creator within that project. Projects with no * user-touched tickets fall to the bottom, sorted alphabetically. * * Distinct from getProjectsUserHasAccessTo (alphabetical) — this * surfaces "projects I'm actively working in" to the top, which is * what mobile's filter sheet wants for its top-N preview. Using * `project.modified` would pick up activity by anyone on the * project (not what the user asked for); this uses ticket-edit * activity scoped to the user. */ public function getProjectsByUserActivity(): false|array { $userId = (int) session('userdata.id'); if ($userId === 0) { return false; } // projectStatus 'open' excludes archived projects (state === -1) at the // query level — "projects I'm actively working in" shouldn't surface // archived ones. Without it this defaulted to 'all' and returned // archived projects too (they were padding the mobile project picker). $projects = $this->projectRepository->getUserProjects(userId: $userId, projectStatus: 'open', accessStatus: 'all'); if (! $projects) { return false; } $projectIds = array_filter(array_map(fn ($p) => (int) ($p['id'] ?? 0), $projects)); if (empty($projectIds)) { return $projects; } // Bulk-fetch per-project max(modified) where the user touched a // ticket — one query, then merge into the projects array. No N+1. $connection = app()->make(\Illuminate\Database\Connection::class); $rows = $connection->table('zp_tickets') ->select('projectId') ->selectRaw('MAX(modified) AS user_last_activity') ->whereIn('projectId', $projectIds) ->where(function ($q) use ($userId) { $q->where('editorId', (string) $userId) ->orWhere('userId', $userId); }) ->groupBy('projectId') ->get(); $activity = []; foreach ($rows as $row) { $activity[(int) $row->projectId] = $row->user_last_activity; } foreach ($projects as &$p) { $p['userLastActivity'] = $activity[(int) ($p['id'] ?? 0)] ?? null; } unset($p); usort($projects, function ($a, $b) { $aActivity = $a['userLastActivity'] ?? null; $bActivity = $b['userLastActivity'] ?? null; if ($aActivity && $bActivity) { // YYYY-MM-DD HH:MM:SS sorts correctly via strcmp; desc. return strcmp($bActivity, $aActivity); } if ($aActivity) { return -1; } if ($bActivity) { return 1; } return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? '')); }); return $projects; } /** * Sets the current project for the user. * If projectId is present in the query string, it sets the project based on that. * If projectId is not present, it checks if the currentProject is set in the session and sets the project based on that. * If currentProject is not set, it sets the currentProject to 0. * If lastProject setting is set in the user's settings, it sets the project based on that. * If lastProject setting is not set, it sets the currentProject to the first project assigned to the user. * If no projects are assigned to the user, it throws an Exception. * * * @throws \Exception */ public function setCurrentProject(): void { if (isset($_GET['projectId']) === true) { $projectId = filter_var($_GET['projectId'], FILTER_SANITIZE_NUMBER_INT); if ($this->changeCurrentSessionProject($projectId) === true) { return; } } if ( session()->has('currentProject') && $this->changeCurrentSessionProject(session('currentProject')) ) { return; } session(['currentProject' => 0]); // If last project setting is set use that $lastProject = $this->settingsRepo->getSetting('usersettings.'.session('userdata.id').'.lastProject'); if ( ! empty($lastProject) && $this->changeCurrentSessionProject($lastProject) ) { return; } $allProjects = $this->getProjectsAssignedToUser(session('userdata.id')); if (empty($allProjects)) { return; } if ($this->changeCurrentSessionProject($allProjects[0]['id']) === true) { return; } throw new \Exception('Error trying to set a project'); } /** * Gets the current project ID. * If the session variable "currentProject" is set, it returns its integer value. * Otherwise, it returns 0. * * @return int The current project ID. */ public function getCurrentProjectId(): int { // Make sure that we never return a value less than 0. return max(0, (int) (session('currentProject') ?? 0)); } /** * Change the current session project to the specified projectId. * * @param mixed $projectId The ID of the project to set as current. * @return bool Returns true if the current project is successfully changed, false otherwise. * * @api */ public function changeCurrentSessionProject($projectId): bool { if (! is_numeric($projectId)) { return false; } $projectId = (int) $projectId; if ( session()->exists('currentProject') && session('currentProject') == $projectId ) { return true; } session(['currentProjectName' => '']); if ($this->isUserAssignedToProject(session('userdata.id'), $projectId) === true) { // Get user project role $project = $this->getProject($projectId); if ($project) { if ( session()->exists('currentProject') && session('currentProject') == $project['id'] ) { return true; } $projectRole = $this->getProjectRole(session('userdata.id'), $projectId); session(['currentProject' => $projectId]); if (mb_strlen($project['name']) > 25) { session(['currentProjectName' => mb_substr($project['name'], 0, 25).' (...)']); } else { session(['currentProjectName' => $project['name']]); } session(['currentProjectClient' => $project['clientName']]); session(['userdata.projectRole' => '']); if ($projectRole != '') { session(['userdata.projectRole' => Roles::getRoleString($projectRole)]); } session(['currentSprint' => '']); session(['currentIdeaCanvas' => '']); session(['lastTicketView' => '']); session(['lastFilterdTicketTableView' => '']); session(['lastFilterdTicketKanbanView' => '']); session(['currentWiki' => '']); session(['lastArticle' => '']); session(['currentSWOTCanvas' => '']); session(['currentLEANCanvas' => '']); session(['currentEMCanvas' => '']); session(['currentINSIGHTSCanvas' => '']); session(['currentSBCanvas' => '']); session(['currentRISKSCanvas' => '']); session(['currentEACanvas' => '']); session(['currentLBMCanvas' => '']); session(['currentOBMCanvas' => '']); session(['currentDBMCanvas' => '']); session(['currentSQCanvas' => '']); session(['currentCPCanvas' => '']); session(['currentSMCanvas' => '']); session(['currentRETROSCanvas' => '']); $this->settingsRepo->saveSetting('usersettings.'.session('userdata.id').'.lastProject', session('currentProject')); $recentProjects = $this->settingsRepo->getSetting('usersettings.'.session('userdata.id').'.recentProjects'); $recent = safe_unserialize($recentProjects, []); if (is_array($recent) === false) { $recent = []; } $key = array_search(session('currentProject'), $recent); if ($key !== false) { unset($recent[$key]); } array_unshift($recent, session('currentProject')); $recent = array_slice($recent, 0, 20); $this->settingsRepo->saveSetting('usersettings.'.session('userdata.id').'.recentProjects', serialize($recent)); session()->forget('projectsettings'); self::dispatch_event('projects.setCurrentProject', $project); return true; } else { return false; } } else { return false; } } /** * Resets the current project by clearing all session data related to the project. */ public function resetCurrentProject(): void { session(['currentProject' => '']); session(['currentProjectClient' => '']); session(['currentProjectName' => '']); session(['currentSprint' => '']); session(['currentIdeaCanvas' => '']); session(['currentSWOTCanvas' => '']); session(['currentLEANCanvas' => '']); session(['currentEMCanvas' => '']); session(['currentINSIGHTSCanvas' => '']); session(['currentSBCanvas' => '']); session(['currentRISKSCanvas' => '']); session(['currentEACanvas' => '']); session(['currentLBMCanvas' => '']); session(['currentOBMCanvas' => '']); session(['currentDBMCanvas' => '']); session(['currentSQCanvas' => '']); session(['currentCPCanvas' => '']); session(['currentSMCanvas' => '']); session(['currentRETROSCanvas' => '']); session()->forget('projectsettings'); $this->settingsRepo->saveSetting('usersettings.'.session('userdata.id').'.lastProject', session('currentProject')); $this->setCurrentProject(); } /** * Gets all users that have access to a project. * For direct access only set the teamOnly flag to true * * @param int $projectId The ID of the project. * @return array An array of users assigned to the project. * * @api */ #[RequiresPermission(ProjectsPermissions::VIEW, projectIdParam: 'projectId')] public function getUsersAssignedToProject($projectId, $teamOnly = false): array { $users = $this->projectRepository->getUsersAssignedToProject($projectId, $teamOnly); foreach ($users as $key => $user) { if (dtHelper()->isValidDateString($user['modified'])) { $users[$key]['modified'] = dtHelper()->parseDbDateTime($user['modified'])->toIso8601ZuluString(); } else { $users[$key]['modified'] = null; } } if ($users) { return $users; } return []; } /** * Gets all users that can access a project, honoring the project's access level * (psettings): directly assigned users plus — depending on the setting — all * active users ('all') or the client's active users ('clients'). * * Use this for assignee/user pickers; use getUsersAssignedToProject() when only * the directly assigned team is wanted (e.g. notifications). * * @param int $projectId The ID of the project. * @return array The users with access to the project. * * @api */ #[RequiresPermission(ProjectsPermissions::VIEW, projectIdParam: 'projectId')] public function getUsersWithAccessToProject(int $projectId): array { $users = $this->projectRepository->getUsersWithAccessToProject($projectId); foreach ($users as $key => $user) { if (dtHelper()->isValidDateString($user['modified'])) { $users[$key]['modified'] = dtHelper()->parseDbDateTime($user['modified'])->toIso8601ZuluString(); } else { $users[$key]['modified'] = null; } } return $users; } /** * Checks if a user is assigned to a particular project. * * @param int $userId The ID of the user being checked. * @param int $projectId The ID of the project being checked. * @return bool Returns true if the user is assigned to the project, false otherwise. * * @api */ public function isUserAssignedToProject(int $userId, int $projectId): bool { return $this->projectRepository->isUserAssignedToProject($userId, $projectId); } /** * Checks if a user is a member of a specific project. * * @param int $userId - The ID of the user. * @param int $projectId - The ID of the project. * @return bool - Returns true if the user is a member of the project, otherwise false. * * @api */ public function isUserMemberOfProject(int $userId, int $projectId): bool { return $this->projectRepository->isUserMemberOfProject($userId, $projectId); } /** * Adds a new project. * * @param array $values The project data. * - name: string (required) The name of the project. * - details: string (optional) Additional details about the project. * - clientId: int (required) The ID of the client associated with the project. * - hourBudget: int (optional) The hour budget for the project (defaults to 0). * - assignedUsers: string (optional) The list of assigned users (defaults to an empty string). * - dollarBudget: int (optional) The dollar budget for the project (defaults to 0). * - psettings: string (optional) The project settings (defaults to 'restricted'). * - type: string (fixed value 'project') The type of the project. * - parent: int (optional) Id of a container project (program/strategy) to nest * the new project under. Ignored unless it references a program or strategy. * - start: string|null The start date of the project in user format or null. * - end: string|null The end date of the project in user format or null. * @return int|false The ID of the added project, or false if the project could not be added. * * @api */ #[RequiresPermission(ProjectsPermissions::CREATE, global: true)] public function addProject(array $values): int|false { // A project may only be nested under a CONTAINER project (a program or a strategy), // never under another regular project. Validated here (not just in the controller) // because this method is also reachable via JSON-RPC. $parent = null; if (! empty($values['parent'])) { $parentProject = $this->projectRepository->getProject((int) $values['parent']); if (is_array($parentProject) && in_array($parentProject['type'] ?? '', ['program', 'strategy'], true)) { $parent = (int) $values['parent']; } } $values = [ 'name' => $values['name'], 'details' => $values['details'] ?? '', 'clientId' => $values['clientId'], 'hourBudget' => $values['hourBudget'] ?? 0, 'assignedUsers' => $values['assignedUsers'] ?? '', 'dollarBudget' => $values['dollarBudget'] ?? 0, 'psettings' => $values['psettings'] ?? 'restricted', 'type' => 'project', 'parent' => $parent, 'start' => $values['start'] ?? null, 'end' => $values['end'] ?? null, ]; if ($values['start'] != null) { $values['start'] = format(value: $values['start'], fromFormat: FromFormat::UserDateStartOfDay)->isoDateTime(); } if ($values['end'] != null) { $values['end'] = format($values['end'], fromFormat: FromFormat::UserDateEndOfDay)->isoDateTime(); } return $this->projectRepository->addProject($values); } /** * Duplicates a project with the specified details. * * @param int $projectId The ID of the project to duplicate. * @param int $clientId The ID of the client for the duplicate project. * @param string $projectName The name of the duplicate project. * @param string $userStartDate The start date of the duplicate project in the format specified by the language setting. * @param bool $assignSameUsers Whether to assign the same users as the original project. * @return bool|int Returns true if the project was successfully duplicated, or the ID of the new project if successful. * * @api */ #[RequiresPermission(ProjectsPermissions::CREATE, global: true)] public function duplicateProject(int $projectId, int $clientId, string $projectName, string $userStartDate, bool $assignSameUsers): bool|int { if (! empty($userStartDate)) { try { $startDate = dtHelper()->parseUserDateTime($userStartDate)->startOfDay(); } catch (\Exception $e) { $startDate = dtHelper()->userNow()->startOfDay(); } } // Ignoring // Comments, files, timesheets, personalCalendar EventDispatcher $oldProjectId = $projectId; // Copy project Entry $projectValues = $this->getProject($projectId); $copyProject = [ 'name' => $projectName, 'clientId' => $clientId, 'details' => $projectValues['details'], 'state' => $projectValues['state'], 'hourBudget' => $projectValues['hourBudget'], 'dollarBudget' => $projectValues['dollarBudget'], 'menuType' => $projectValues['menuType'], 'psettings' => $projectValues['psettings'], 'assignedUsers' => [], ]; if ($assignSameUsers) { $projectUsers = $this->projectRepository->getUsersAssignedToProject($projectId); foreach ($projectUsers as $user) { $copyProject['assignedUsers'][] = ['id' => $user['id'], 'projectRole' => $user['projectRole']]; } } $projectSettingsKeys = ['retrolabels', 'ticketlabels', 'idealabels']; $newProjectId = $this->projectRepository->addProject($copyProject); // ProjectSettings foreach ($projectSettingsKeys as $key) { $setting = $this->settingsRepo->getSetting('projectsettings.'.$projectId.'.'.$key); if ($setting !== false) { $this->settingsRepo->saveSetting('projectsettings.'.$newProjectId.'.'.$key, $setting); } } // Duplicate all todos without dependent Ticket set $allTickets = $this->ticketRepository->getAllByProjectId($projectId); // Checks the oldest editFrom date and makes this the start date $oldestTicket = dtHelper()->now(); foreach ($allTickets as $ticket) { if (dtHelper()->isValidDateString($ticket->editFrom)) { $ticketDateTimeObject = dtHelper()->parseDbDateTime($ticket->editFrom); if ($oldestTicket > $ticketDateTimeObject) { $oldestTicket = $ticketDateTimeObject; } } if (dtHelper()->isValidDateString($ticket->dateToFinish)) { $ticketDateTimeObject = dtHelper()->parseDbDateTime($ticket->dateToFinish); if ($oldestTicket > $ticketDateTimeObject) { $oldestTicket = $ticketDateTimeObject; } } } $projectStart = $startDate ?? dtHelper()->now()->startOfDay(); // Get interval from oldest ticket to project start date $interval = $oldestTicket->diff($projectStart); // oldId = > newId $ticketIdList = []; // Create all tickets first foreach ($allTickets as $ticket) { $dateToFinishValue = ''; if (dtHelper()->isValidDateString($ticket->dateToFinish)) { $dateToFinish = dtHelper()->parseDbDateTime($ticket->dateToFinish); $dateToFinishValue = $dateToFinish->add($interval)->formatDateTimeForDb(); } $editFromValue = ''; if (dtHelper()->isValidDateString($ticket->editFrom)) { $editFrom = dtHelper()->parseDbDateTime($ticket->editFrom); $editFromValue = $editFrom->add($interval)->formatDateTimeForDb(); } $editToValue = ''; if (dtHelper()->isValidDateString($ticket->editTo)) { $editTo = dtHelper()->parseDbDateTime($ticket->editTo); $editToValue = $editTo->add($interval)->formatDateTimeForDb(); } $ticketValues = [ 'headline' => $ticket->headline, 'type' => $ticket->type, 'description' => $ticket->description, 'projectId' => $newProjectId, 'editorId' => $ticket->editorId, 'userId' => session('userdata.id'), 'date' => date('Y-m-d H:i:s'), 'dateToFinish' => $dateToFinishValue, 'status' => $ticket->status, 'storypoints' => $ticket->storypoints, 'hourRemaining' => $ticket->hourRemaining, 'planHours' => $ticket->planHours, 'priority' => $ticket->priority, 'sprint' => '', 'acceptanceCriteria' => $ticket->acceptanceCriteria, 'tags' => $ticket->tags, 'editFrom' => $editFromValue, 'editTo' => $editToValue, 'dependingTicketId' => '', 'milestoneid' => '', ]; $newTicketId = $this->ticketRepository->addTicket($ticketValues); $ticketIdList[$ticket->id] = $newTicketId; } // Iterate through all and update relationships foreach ($allTickets as $ticket) { $values = []; if (! empty($ticket->milestoneid)) { $values['milestoneId'] = $ticketIdList[$ticket->milestoneid] ?? null; if ($values['milestoneId'] === null) { Log::warning('Issue copying project. New Milestone was not found.'); } } if (! empty($ticket->dependingTicketId)) { $values['dependingTicketId'] = $ticketIdList[$ticket->dependingTicketId] ?? null; if ($values['dependingTicketId'] === null) { Log::warning('Issue copying project. New ticket dependency was not found.'); } } $newTicketId = $ticketIdList[$ticket->id] ?? null; if ($newTicketId && ! empty($values)) { $this->ticketRepository->patchTicket($ticket->id, $values); } } // Ideas $this->duplicateCanvas( repository: IdeaRepository::class, originalProjectId: $projectId, newProjectId: $newProjectId ); $this->duplicateCanvas( repository: GoalcanvaRepository::class, originalProjectId: $projectId, newProjectId: $newProjectId ); $this->duplicateCanvas( repository: Wiki::class, originalProjectId: $projectId, newProjectId: $newProjectId, canvasTypeName: 'wiki' ); $this->duplicateCanvas( repository: BlueprintsRepository::class, originalProjectId: $projectId, newProjectId: $newProjectId, canvasTypeName: 'leancanvas' ); self::dispatchEvent('projectDuplicated', ['projectId' => $projectId, 'newProjectId' => $newProjectId, 'startDate' => $projectStart, 'interval' => $interval]); return $newProjectId; } /** * Duplicate a canvas from one project to another. * * @param string $repository The repository class to use for CRUD operations * @param int $originalProjectId The ID of the original project * @param int $newProjectId The ID of the new project * @param string $canvasTypeName The canvas type name (optional) * @return bool True if the canvas is duplicated successfully, false otherwise */ private function duplicateCanvas(string $repository, int $originalProjectId, int $newProjectId, string $canvasTypeName = ''): bool { $canvasIdList = []; $canvasRepo = app()->make($repository); $canvasBoards = $canvasRepo->getAllCanvas($originalProjectId, $canvasTypeName); // The consolidated Blueprints repository derives its comment module from // the canvas type and requires it to be passed explicitly to // getCanvasItemsById(). Legacy domain repositories (Ideas, Goalcanvas, // Wiki) take a single id argument and derive the module themselves, so // only build/pass the module for "canvas" types. $commentModule = str_ends_with($canvasTypeName, 'canvas') ? $canvasTypeName.'item' : null; foreach ($canvasBoards as $canvas) { $canvasValues = [ 'title' => $canvas['title'], 'author' => session('userdata.id'), 'projectId' => $newProjectId, 'description' => $canvas['description'] ?? '', ]; $newCanvasId = $canvasRepo->addCanvas($canvasValues, $canvasTypeName); $canvasIdList[$canvas['id']] = $newCanvasId; $canvasItems = $commentModule === null ? $canvasRepo->getCanvasItemsById($canvas['id']) : $canvasRepo->getCanvasItemsById($canvas['id'], $commentModule); if ($canvasItems && count($canvasItems) > 0) { // Build parent Array // oldId => newId $idMap = []; foreach ($canvasItems as $item) { $milestoneId = ''; if (isset($idMap[$item['milestoneId']])) { $milestoneId = $idMap[$item['milestoneId']]; } $canvasItemValues = [ 'description' => $item['description'] ?? '', 'assumptions' => $item['assumptions'] ?? '', 'data' => $item['data'] ?? '', 'conclusion' => $item['conclusion'] ?? '', 'box' => $item['box'] ?? '', 'author' => $item['author'] ?? '', 'canvasId' => $newCanvasId, 'sortindex' => $item['sortindex'] ?? '', 'status' => $item['status'] ?? '', 'relates' => $item['relates'] ?? '', 'milestoneId' => $milestoneId, 'title' => $item['title'] ?? '', 'parent' => $item['parent'] ?? '', 'featured' => $item['featured'] ?? '', 'tags' => $item['tags'] ?? '', 'kpi' => $item['kpi'] ?? '', 'data1' => $item['data1'] ?? '', 'data2' => $item['data2'] ?? '', 'data3' => $item['data3'] ?? '', 'data4' => $item['data4'] ?? '', 'data5' => $item['data5'] ?? '', 'startDate' => '', 'endDate' => '', 'setting' => $item['setting'] ?? '', 'metricType' => $item['metricType'] ?? '', 'startValue' => '', 'currentValue' => '', 'endValue' => $item['endValue'] ?? '', 'impact' => $item['impact'] ?? '', 'effort' => $item['effort'] ?? '', 'probability' => $item['probability'] ?? '', 'action' => $item['action'] ?? '', 'assignedTo' => $item['assignedTo'] ?? '', ]; $newId = $canvasRepo->addCanvasItem($canvasItemValues); $idMap[$item['id']] = $newId; } // Now fix relates to and parent relationships $newCanvasItems = $commentModule === null ? $canvasRepo->getCanvasItemsById($newCanvasId) : $canvasRepo->getCanvasItemsById($newCanvasId, $commentModule); foreach ($canvasItems as $newItem) { $newCanvasItemValues = [ 'relates' => ($newItem['relates'] ?? false) ? ($idMap[$newItem['relates']] ?? '') : '', 'parent' => ($newItem['relates'] ?? false) ? ($idMap[$newItem['parent']] ?? '') : '', ]; $canvasRepo->patchCanvasItem($newItem['id'], $newCanvasItemValues); } } } return true; } /** * Patches a project with partial updates. * * Unlike editProject(), this method only updates the fields provided in $params, * preserving all other existing values including the project type. * * Recommended for API usage to avoid accidentally overwriting fields. * * @param int $id The ID of the project. * @param array $params Fields to update (only these fields will be changed). * @return bool Returns true if the project was successfully updated, false otherwise. * * @api */ #[RequiresPermission(ProjectsPermissions::EDIT, global: true)] public function patch($id, $params): bool { $params = $this->rejectCyclicParent((int) $id, $params); return $this->projectRepository->patch($id, $params); } /** * Drops a parent assignment that would make the project its own ancestor. * * A project pointing at itself (or at one of its descendants) creates a cycle in * the hierarchy, which used to hang the project selector on every page. Invalid * assignments are removed from the value set so the stored parent stays unchanged. * * @param int $projectId The project being written. * @param array $values The values about to be persisted. * @return array The values with any cyclic parent assignment removed. */ private function rejectCyclicParent(int $projectId, array $values): array { if (empty($values['parent'])) { return $values; } $current = (int) $values['parent']; $steps = 0; while ($current > 0 && $steps < 100) { if ($current === $projectId) { Log::warning("Rejected parent assignment for project {$projectId}: parent {$values['parent']} would create a hierarchy cycle."); unset($values['parent']); return $values; } $parentProject = $this->projectRepository->getProject($current); $current = (int) ($parentProject['parent'] ?? 0); $steps++; } return $values; } /** * Retrieves the avatar for a project. * * @param mixed $id The ID of the project. * @return SVG|Response|string Returns either an SVG file, a file response or a path to a file * * @api */ public function getProjectAvatar($id): SVG|Response|string { $project = $this->projectRepository->getProjectAvatar($id); if (empty($project)) { return $this->avatarcreator->getAvatar('🦄'); } $this->avatarcreator->setFilePrefix('project'); $this->avatarcreator->setBackground('#555555'); // If user uploaded return uploaded file if (! empty($project['avatar'])) { $file = $this->fileService->getFileById($project['avatar']); if ($file) { return $file; } } $avatar = $this->avatarcreator->getAvatar($project['name']); return self::dispatch_filter('afterGettingAvatar', $avatar, ['projectId' => $id]); } /** * Sets the avatar for a project. * * @param mixed $file The file containing the avatar. * @param mixed $projectId The id of the project. * @return bool Indicates whether the avatar was successfully set. * * @throws BindingResolutionException * * @internal Not @api: invoked only by Projects\Controllers\ProjectImage, which * authorizes the upload (manager+ on the target project). Exposing a * setter that trusts a caller-supplied $projectId over JSON-RPC would * let any user overwrite another project's avatar. */ #[RequiresPermission(ProjectsPermissions::EDIT, global: true)] public function setProjectAvatar($file, $projectId): bool { $project = $this->projectRepository->getProject($projectId); // Save the path to the old picture $oldPicture = null; if (isset($project['avatar']) && $project['avatar'] > 0) { $oldPicture = $project['avatar']; } $leantimeFile = $this->fileService->upload($file, 'project', $projectId); if ($leantimeFile && $this->projectRepository->setPicture($leantimeFile['fileId'], $projectId) && $oldPicture) { try { $this->fileService->deleteFile($oldPicture); } catch (\Exception $e) { Log::warning('Could not delete old profile picture: '.$e->getMessage()); Log::warning($e); } } return true; } /** * Retrieves all projects. * * @return array The projects. * * @api */ public function getAllProjects() { return $this->projectRepository->getAll(); } /** * Gets all strategy projects. * * @return array All strategies with their details * * @api */ public function getAllStrategies(): array { return $this->projectRepository->getProjectsByType('strategy'); } /** * Gets all program projects. * * @return array All programs with their details * * @api */ public function getAllPrograms(): array { return $this->projectRepository->getProjectsByType('program'); } /** * Retrieves the setup checklist for a project. * * @param int $projectId The ID of the project. * @return array The setup checklist for the project */ #[RequiresPermission(ProjectsPermissions::VIEW, projectIdParam: 'projectId')] public function getProjectSetupChecklist($projectId): array { $progressSteps = [ 'define' => [ 'title' => 'label.define', 'description' => 'checklist.define.description', 'tasks' => [ 'description' => [ 'title' => 'label.projectDescription', 'status' => '', 'link' => BASE_URL.'/projects/showProject/'.session('currentProject').'', 'description' => 'checklist.define.tasks.description', ], 'defineTeam' => [ 'title' => 'label.defineTeam', 'status' => '', 'link' => BASE_URL.'/projects/showProject/'.session('currentProject').'#team', 'description' => 'checklist.define.tasks.defineTeam', ], 'createBlueprint' => [ 'title' => 'label.createBlueprint', 'status' => '', 'link' => BASE_URL.'/blueprints/showBoards/', 'description' => 'checklist.define.tasks.createBlueprint', ], ], 'status' => '', ], 'goals' => [ 'title' => 'label.setGoals', 'description' => 'checklist.goals.description', 'tasks' => [ 'setGoals' => [ 'title' => 'label.setGoals', 'status' => '', 'link' => BASE_URL.'/goalcanvas/dashboard', 'description' => 'checklist.goals.tasks.setGoals', ], ], 'status' => '', ], 'timeline' => [ 'title' => 'label.setTimeline', 'description' => 'checklist.timeline.description', 'tasks' => [ 'createMilestones' => [ 'title' => 'label.createMilestones', 'status' => '', 'link' => BASE_URL.'/tickets/roadmap', 'description' => 'checklist.timeline.tasks.createMilestones', ], ], 'status' => '', ], 'implementation' => [ 'title' => 'label.implement', 'description' => 'checklist.implementation.description', 'tasks' => [ 'createTasks' => [ 'title' => 'label.createTasks', 'status' => '', 'link' => BASE_URL.'/tickets/showAll', 'description' => 'checklist.implementation.tasks.createTasks ', ], 'finish80percent' => [ 'title' => 'label.finish80percent', 'status' => '', 'link' => BASE_URL.'/reports/show', 'description' => 'checklist.implementation.tasks.finish80percent', ], ], 'status' => '', ], ]; // Todo determine tasks that are done. $project = $this->getProject($projectId); // Project Description if ($project['details'] != '') { $progressSteps['define']['tasks']['description']['status'] = 'done'; } /* if ($project['numUsers'] > 1) { $progressSteps["define"]["tasks"]["defineTeam"]["status"] = "done"; } if ($project['numDefinitionCanvas'] >= 1) { $progressSteps["define"]["tasks"]["createBlueprint"]["status"] = "done"; }*/ $goals = app()->make(GoalcanvaRepository::class); $allCanvas = $goals->getAllCanvas($projectId); $totalGoals = 0; foreach ($allCanvas as $goalsCanvas) { $totalGoals = $totalGoals + $goalsCanvas['boxItems']; } if ($totalGoals > 0) { $progressSteps['define']['goals']['setGoals']['status'] = 'done'; } /* if ($project['numberMilestones'] >= 1) { $progressSteps["timeline"]["tasks"]["createMilestones"]["status"] = "done"; } if ($project['numberOfTickets'] >= 1) { $progressSteps["implementation"]["tasks"]["createTasks"]["status"] = "done"; }*/ $percentDone = $this->getProjectProgress($projectId); if ($percentDone['percent'] >= 80) { $progressSteps['implementation']['tasks']['finish80percent']['status'] = 'done'; } // Add overrides if (! $stepsCompleted = $this->settingsRepo->getSetting("projectsettings.$projectId.stepsComplete")) { $stepsCompleted = []; } else { $stepsCompleted = safe_unserialize($stepsCompleted, []); } $stepsCompleted = array_map(fn ($status) => 'done', $stepsCompleted); $halfStep = (1 / count($progressSteps)) / 2 * 100; $position = 0; $debug = []; foreach ($progressSteps as $name => $step) { // set the "left" css position for the step on the progress bar $progressSteps[$name]['positionLeft'] = ($position++ / count($progressSteps) * 100) + $halfStep; // set the status based on the stepsCompleted setting data_set( $progressSteps, "$name.tasks", collect(data_get($progressSteps, "$name.tasks")) ->map(function ($task, $key) use ($stepsCompleted) { $task['status'] = $stepsCompleted[$key] ?? ''; return $task; }) ->toArray() ); // check for any open tasks if (in_array('', data_get($progressSteps, "$name.tasks.*.status"))) { if ( $name == array_key_first($progressSteps) || ($previousValue['stepType'] ?? '') == 'complete' ) { $progressSteps[$name]['stepType'] = 'current'; } else { $progressSteps[$name]['stepType'] = ''; } $progressSteps[$name]['status'] = ''; $previousValue = $progressSteps[$name]; continue; } // otherwise, set the step as completed $progressSteps[$name]['status'] = 'done'; if ( ! in_array($previousValue['stepType'] ?? null, ['current', '']) || $name == array_key_first($progressSteps) ) { $progressSteps[$name]['stepType'] = 'complete'; } else { $progressSteps[$name]['stepType'] = ''; } $previousValue = $progressSteps[$name]; } // Set the Percentage done of the progress Bar $numberDone = count(array_filter(data_get($progressSteps, '*.stepType'), fn ($status) => $status == 'complete')); $stepsTotal = count($progressSteps); $percentDone = $numberDone == $stepsTotal ? 100 : $numberDone / $stepsTotal * 100 + $halfStep; return [ $progressSteps, $percentDone, ]; } /** * Updates the progress of a project. * * @param string|array $stepsComplete The steps completed for the project. * @param int $projectId The ID of the project. */ public function updateProjectProgress($stepsComplete, $projectId): void { if (empty($stepsComplete)) { return; } $stepsDoneArray = []; if (is_string($stepsComplete)) { parse_str($stepsComplete, $stepsDoneArray); } else { $stepsDoneArray = $stepsComplete; } $this->settingsRepo->saveSetting( "projectsettings.$projectId.stepsComplete", serialize($stepsDoneArray) ); } /** * Edits the project relations of a user. * * @param int $id The ID of the user. * @param array $projects The projects to be edited. * @return bool True if the project relations were successfully edited, false otherwise. * * @api */ #[RequiresPermission(ProjectsPermissions::EDIT, global: true)] public function editUserProjectRelations($id, $projects): bool { return $this->projectRepository->editUserProjectRelations($id, $projects); } /** * Adds a single user to a project, leaving their other project * relations untouched. * * Exists because {@see self::editUserProjectRelations()} is a full * REPLACE — it deletes any relation not present in the array it is * given. Callers that only want to add one membership had to read the * user's current projects, append, and write the whole set back; if * that read returned an incomplete list for any reason, the write * silently deleted every other assignment the user had. This method * removes the need for that read-modify-write entirely. * * Idempotent: an existing membership is left alone and reported as * false rather than inserted twice. The check matters because * `zp_relationuserproject` has no unique index on * (userId, projectId) — a blind insert would produce duplicate rows * and show the person twice on the project team. * * Permission is deliberately identical to editUserProjectRelations * (global projects.edit, i.e. manager+). Assigning users to projects * is a company-scoped action; gating this per-project instead would * let a project-scoped editor grant access to a project, which is * more power than they have today. * * @param int $userId The user to add. * @param int $projectId The project to add them to. * @param string $projectRole Optional per-project role. * @return bool True when a relation was created, false when the user * was already a member (or the ids were invalid). * * @api */ #[RequiresPermission(ProjectsPermissions::EDIT, global: true)] public function addUserToProject(int $userId, int $projectId, string $projectRole = ''): bool { if ($userId <= 0 || $projectId <= 0) { return false; } // Fail closed on ids that don't resolve to a real row. isUserMemberOfProject() // returns false for a missing user or project, so without this guard // addProjectRelation() would blindly write an orphan relation row — // corrupting zp_relationuserproject and breaking downstream listeners that // assume both ids resolve. if (empty($this->userRepo->getUser($userId)) || empty($this->projectRepository->getProject($projectId))) { return false; } // Idempotence is a check-then-insert. zp_relationuserproject has no unique // index on (userId, projectId), so two concurrent adds could both pass this // check and each insert. That's bounded (a duplicate membership row, not // corruption); a unique index is the real fix and is left as a follow-up. // isUserMemberOfProject, NOT isUserAssignedToProject: the latter // answers "can this user reach the project", which is true for // every admin and owner regardless of any relation row. Using it // here would make the method a silent no-op for exactly those // users — an admin could never be added to a project team, and // callers would get false ("already a member") for someone who // isn't on the team at all. Membership is what we're writing, so // membership is what we check. if ($this->projectRepository->isUserMemberOfProject($userId, $projectId)) { return false; } $this->projectRepository->addProjectRelation($userId, $projectId, $projectRole); return true; } /** * Removes all project relations for a given user. * * @param int $userId The user ID * * @api */ #[RequiresPermission(ProjectsPermissions::EDIT, global: true)] public function deleteAllUserProjectRelations(int $userId): void { $this->projectRepository->deleteAllProjectRelations($userId); } /** * Retrieves the project relations for a given user. * * @param int $userId The user ID * @param int|null $projectId Optional project ID filter * @return array The project relations * * @api */ public function getUserProjectRelation(int $userId, ?int $projectId = null): array { return $this->projectRepository->getUserProjectRelation($userId, $projectId); } /** * Retrieves the ID of a project by its name. * * @param array $allProjects The array of all projects. * @param string $projectName The name of the project to retrieve the ID for. * @return mixed The ID of the project if found, or false if not found. * * @api */ public function getProjectIdbyName(?array $allProjects, string $projectName) { if ($allProjects == null) { $allProjects = $this->getAll(); } foreach ($allProjects as $project) { if (strtolower(trim($project['name'])) == strtolower(trim($projectName))) { return $project['id']; } } return false; } /** * Updates the sorting of multiple projects and tickets in Program Timeline. * * Handles mixed payload from Program Timeline Gantt chart which contains both: * - Project IDs prefixed with "pgm-" (e.g., "pgm-123") * - Ticket IDs prefixed with "ticket-" (e.g., "ticket-456") * * @param array $params The array containing IDs as keys and sort positions as values. * @return bool Returns true if the sorting update was successful, false otherwise. */ public function updateProjectSorting($params): bool { $projectUpdates = []; $ticketUpdates = []; // Separate projects from tickets based on ID prefix foreach ($params as $id => $sortPosition) { if (str_starts_with($id, 'pgm-')) { // Extract numeric project ID $projectId = (int) substr($id, 4); $projectUpdates[$projectId] = $sortPosition; } elseif (str_starts_with($id, 'ticket-')) { // Extract numeric ticket ID $ticketId = (int) substr($id, 7); $ticketUpdates[$ticketId] = $sortPosition; } else { // Legacy: plain numeric IDs are projects $projectUpdates[$id] = $sortPosition; } } // Update projects foreach ($projectUpdates as $projectId => $sortPosition) { if ($this->projectRepository->patch($projectId, ['sortIndex' => $sortPosition * 100]) === false) { return false; } } // Update tickets (milestones) using the Tickets service if (! empty($ticketUpdates)) { $ticketService = app()->make(\Leantime\Domain\Tickets\Services\Tickets::class); if (! $ticketService->updateTicketSorting($ticketUpdates)) { return false; } } return true; } /** * Edits a project. * * IMPORTANT: If 'type' is not provided in $values, it will be preserved from the existing project. * To change the type, explicitly include it in $values. * * For partial updates that only modify specified fields, consider using patch() instead. * * @param mixed $values The values to be updated in the project. * @param int $id The ID of the project to be edited. * @return void * * @api */ #[RequiresPermission(ProjectsPermissions::EDIT, global: true)] public function editProject($values, $id) { $values = $this->rejectCyclicParent((int) $id, $values); // Preserve existing type if not provided if (! isset($values['type'])) { $currentProject = $this->getProject($id); if ($currentProject) { $values['type'] = $currentProject['type'] ?? 'project'; } else { $values['type'] = 'project'; } } $this->projectRepository->editProject($values, $id); } /** * Deletes a project and all associated user relations. * * Managers and above (manager/admin/owner) can delete any project, company-wide * (projects.delete is a global, manager+ capability). * * @param int $id The project ID to delete * @return bool True if deleted, false if unauthorized * * @api */ #[RequiresPermission(ProjectsPermissions::DELETE, global: true)] public function deleteProject(int $id): bool { if (! Auth::userIsAtLeast(Roles::$manager)) { return false; } $this->projectRepository->deleteProject($id); $this->projectRepository->deleteAllUserRelations($id); return true; } /** * Checks if a project has any tickets. * * @param int $id The project ID * @return bool True if the project has tickets * * @api */ #[RequiresPermission(ProjectsPermissions::VIEW, projectIdParam: 'id')] public function hasTickets(int $id): bool { return $this->projectRepository->hasTickets($id); } /** * Saves a project integration webhook setting. * * @param int $projectId The project ID * @param string $key The setting key suffix (e.g. 'mattermostWebhookURL') * @param mixed $value The setting value */ #[RequiresPermission(ProjectsPermissions::EDIT, global: true)] public function saveProjectSetting(int $projectId, string $key, mixed $value): void { $this->settingsRepo->saveSetting('projectsettings.'.$projectId.'.'.$key, $value); } /** * Gets a project integration setting. * * @param int $projectId The project ID * @param string $key The setting key suffix * @return mixed The setting value */ #[RequiresPermission(ProjectsPermissions::VIEW, projectIdParam: 'projectId')] public function getProjectSetting(int $projectId, string $key): mixed { return $this->settingsRepo->getSetting('projectsettings.'.$projectId.'.'.$key); } /** * Saves project user assignments and roles. * * @param int $projectId The project ID * @param array $assignedUsers Array of user IDs * @param array $projectRoles Array of role data from POST */ #[RequiresPermission(ProjectsPermissions::EDIT, global: true)] public function updateProjectUsers(int $projectId, array $assignedUsers, array $projectRoles): void { $values = [ 'assignedUsers' => $assignedUsers, 'projectRoles' => $projectRoles, ]; $this->projectRepository->editProjectRelations($values, $projectId); } /** * Updates the status and sorting of projects. * * @param array $params An associative array representing the project status and sorting. * The key is the status and the value is the serialized project list. * @param null $handler Optional parameter for handling the project update process. * @return bool Returns true if the update process is successful, false otherwise. */ public function updateProjectStatusAndSorting($params, $handler = null): bool { // Jquery sortable serializes the array for kanban in format // statusKey: item[]=X&item[]=X2..., // statusKey2: item[]=X&item[]=X2..., // This represents status & kanban sorting foreach ($params as $status => $projectList) { if (is_numeric($status) && ! empty($projectList)) { $projects = explode('&', $projectList); if (is_array($projects) === true) { foreach ($projects as $key => $projectString) { $id = substr($projectString, 7); $this->projectRepository->patch($id, ['sortIndex' => $key * 100, 'state' => $status]); } } } } return true; } /** * Authorization gate for managing (sorting/restatusing/patching) a project. * * The project kanban/timeline management UI (Projects\Controllers\ShowAll) is * gated on manager+, so the JSON-RPC entry points below must enforce the same. * Admins and owners can manage every project; managers must be assigned to it. * Not @api: this is an internal authorization helper, reachable by the relocated * image controllers, never callable over JSON-RPC. * * @param int $projectId The project to authorize against * @return bool True if the current user may manage the project */ public function userCanManageProject(int $projectId): bool { if (! Auth::userIsAtLeast(Roles::$manager)) { return false; } if (Auth::userIsAtLeast(Roles::$admin)) { return true; } if ($projectId <= 0) { return false; } return $this->isUserAssignedToProject((int) session('userdata.id'), $projectId); } /** * Authorized JSON-RPC entry point for kanban status + sort of projects. * * The JSON-RPC endpoint has no controller-level role gate, so this wrapper * enforces manager+ and per-project access for every project in the batch * (prevents re-statusing/re-sorting projects the caller cannot manage), then * delegates to the internal updateProjectStatusAndSorting(). * * @param array $params Associative status => jQuery-sortable-serialized project list * @param string|null $handler Optional drag handler id (unused by the update) * @return bool True on success (false only if the underlying write fails) * * @throws AuthorizationException If the caller cannot manage any project in the batch * * @api */ public function patchProjectStatusAndSorting(array $params, ?string $handler = null): bool { foreach ($params as $status => $projectList) { if (! is_numeric($status) || empty($projectList)) { continue; } foreach (explode('&', $projectList) as $projectString) { // jQuery sortable serializes ids as "item[]=ID" (strip the 7-char prefix). $projectId = (int) substr($projectString, 7); if ($projectId <= 0) { continue; } if (! $this->userCanManageProject($projectId)) { throw new AuthorizationException('You are not allowed to re-sort one or more of these projects.'); } } } return $this->updateProjectStatusAndSorting($params, $handler); } /** * Authorized JSON-RPC entry point for Program Timeline (Gantt) re-sorting. * * Validates manager+ access for every entity in the mixed payload (pgm-/ticket- * prefixed and legacy numeric ids) before delegating to updateProjectSorting(). * Ticket ids are resolved to their project so the same manage-access rule applies. * * @param array $params Map of (pgm-{id}|ticket-{id}|{id}) => sort position * @return bool True on success (false only if the underlying write fails) * * @throws NotFoundException If a ticket-{id} key references a ticket that does not exist * @throws AuthorizationException If the caller cannot manage any item in the payload * * @api */ public function sortProjects(array $params): bool { foreach (array_keys($params) as $id) { if (str_starts_with((string) $id, 'ticket-')) { $ticket = $this->ticketRepository->getTicket((int) substr((string) $id, 7)); if (! $ticket) { throw new NotFoundException('A task referenced in the sort order could not be found.'); } $projectId = (int) $ticket->projectId; } elseif (str_starts_with((string) $id, 'pgm-')) { $projectId = (int) substr((string) $id, 4); } else { $projectId = (int) $id; } if (! $this->userCanManageProject($projectId)) { throw new AuthorizationException('You are not allowed to re-sort one or more of these items.'); } } return $this->updateProjectSorting($params); } /** * Authorized JSON-RPC entry point for patching a single project (e.g. Gantt * date/sort changes from the Program Timeline). * * Enforces manager+ access on the target project, strips framework/control * fields, then delegates to the internal patch(). * * @param int $id The project id to patch * @param array $values Fields to update (e.g. start, end, sortIndex) * @return bool True on success (false only if the underlying write fails) * * @throws AuthorizationException If the caller cannot manage the target project * * @api */ #[RequiresPermission(ProjectsPermissions::EDIT, global: true)] public function patchProject(int $id, array $values): bool { if (! $this->userCanManageProject($id)) { throw new AuthorizationException('You are not allowed to edit this project.'); } // Drop control fields that may leak in from the request envelope. unset($values['id'], $values['act'], $values['request_parts']); return $this->patch($id, $values); } /** * Retrieves the projects for a client manager. * * @param int $userId The ID of the user. * @param int $clientId The ID of the client. * @return array The projects for the client manager. * * @api */ public function getClientManagerProjects(int $userId, int $clientId): array { $userId = $this->resolveScopedUserId($userId); $clientProjects = $this->projectRepository->getClientProjects($clientId); $userProjects = $this->projectRepository->getUserProjects($userId); $allProjects = []; foreach ($clientProjects as $project) { if (isset($allProjects[$project['id']]) === false) { $allProjects[$project['id']] = $project; } } foreach ($userProjects as $project) { if (isset($allProjects[$project['id']]) === false) { $allProjects[$project['id']] = $project; } } return $allProjects; } /** * Gets all the projects for the current user. * By default, closed projects are not included. * * @param bool $showClosedProjects (optional) Set to true to include closed projects. * @return array Returns an array of projects. * * @api */ public function getAll(bool $showClosedProjects = false): array { return $this->projectRepository->getUserProjects(userId: session('userdata.id'), accessStatus: 'all', projectTypes: 'project'); } /** * 项目概览甘特图数据:返回 frappe-gantt 兼容的 tasks 数组。 * * 每个项目一条 task:id / name(含负责人 + 风险 badge 的 HTML)/ start / end / * progress / dependencies(子项目指向父项目 id)。父项目进度 = 子项目进度算术平均, * 风险 = 子树内最严重(red > yellow > green),随子项目联动。 * * @param array $projects 已查询好的项目列表(与 ShowAll 控制器同一来源,避免重复查询) * @return array{projects:array,tasks:array>} */ public function getProjectGanttData(array $projects): array { if (empty($projects)) { return ['projects' => [], 'tasks' => []]; } // 防环:清理父子关系(自引用/循环 parent 重新归零) $projects = $this->cleanParentRelationship($projects); // 建立 id -> project 索引,并预取进度、负责人、风险 $byId = []; foreach ($projects as $p) { $p['id'] = (int) $p['id']; $p['parent'] = (int) ($p['parent'] ?? 0); $byId[$p['id']] = $p; } // 预取每个项目的进度 + 负责人 + 风险(避免在聚合循环里重复查询) $meta = []; foreach ($byId as $id => $p) { $progress = 0.0; try { $progress = (float) ($this->getProjectProgress($id)['percent'] ?? 0); } catch (\Throwable $e) { $progress = 0.0; } $users = $this->getUsersAssignedToProject($id, true); $owners = []; foreach ($users as $u) { $name = trim(($u['firstname'] ?? '').' '.($u['lastname'] ?? '')); if ($name !== '') { $owners[] = $name; } } $risk = $this->projectRisk($id); $meta[$id] = [ 'progress' => $progress, 'owners' => $owners, 'risk' => $risk, 'children' => [], ]; } // 建立父子树 foreach ($byId as $id => $p) { $parent = $p['parent']; if ($parent > 0 && isset($byId[$parent]) && $parent !== $id) { $meta[$parent]['children'][] = $id; } } // 自底向上聚合父项目进度与风险(迭代,避免深层递归栈溢出) $aggregatedProgress = []; $aggregatedRisk = []; $compute = function (int $id) use (&$compute, &$meta, &$aggregatedProgress, &$aggregatedRisk) { if (isset($aggregatedProgress[$id])) { return; } $children = $meta[$id]['children']; if (empty($children)) { // 叶子:用自身进度与风险 $aggregatedProgress[$id] = $meta[$id]['progress']; $aggregatedRisk[$id] = $meta[$id]['risk']; return; } $sum = 0.0; $risk = 'green'; foreach ($children as $cid) { $compute($cid); $sum += $aggregatedProgress[$cid]; if ($this->riskSeverity($aggregatedRisk[$cid]) > $this->riskSeverity($risk)) { $risk = $aggregatedRisk[$cid]; } } $aggregatedProgress[$id] = $sum / count($children); $aggregatedRisk[$id] = $risk; }; foreach (array_keys($byId) as $id) { $compute($id); } // 组装 frappe-gantt tasks $tasks = []; foreach ($byId as $id => $p) { $start = $p['start'] ?? ''; $end = $p['end'] ?? ''; // 无起止日期时给一个默认窗口,避免甘特图不显示该条 if (empty($start) || ! dtHelper()->isValidDateString($start)) { $start = dtHelper()->userNow()->format('Y-m-d'); } if (empty($end) || ! dtHelper()->isValidDateString($end)) { $end = dtHelper()->userNow()->addDays(7)->format('Y-m-d'); } $ownerLabel = ''; $owners = $meta[$id]['owners']; if (! empty($owners)) { $first = array_slice($owners, 0, 2); $ownerLabel = implode('、', $first).(count($owners) > 2 ? ' +'.(count($owners) - 2) : ''); } $riskLabel = ''; $riskColor = '#94a3b8'; switch ($aggregatedRisk[$id]) { case 'red': $riskLabel = '高风险'; $riskColor = '#dc2626'; break; case 'yellow': $riskLabel = '有风险'; $riskColor = '#f59e0b'; break; case 'green': $riskLabel = '正常'; $riskColor = '#16a34a'; break; default: $riskLabel = '未标记'; $riskColor = '#94a3b8'; } $name = ''.htmlspecialchars((string) $p['name'], ENT_QUOTES).'' .($ownerLabel !== '' ? ' 👤 '.htmlspecialchars($ownerLabel, ENT_QUOTES).'' : '') .' '.$riskLabel.''; $deps = []; $parent = $p['parent']; if ($parent > 0 && isset($byId[$parent]) && $parent !== $id) { $deps[] = (string) $parent; } $tasks[] = [ 'id' => (string) $id, 'name' => $name, 'start' => $start, 'end' => $end, 'progress' => round($aggregatedProgress[$id], 0), 'dependencies' => implode(',', $deps), 'custom_class' => '', 'type' => 'project', 'bg_color' => ($parent > 0 && isset($byId[$parent]) && $parent !== $id) ? '#c7d2fe' : '#a5b4fc', 'thumbnail' => '', 'sortIndex' => $id, ]; } return ['projects' => $projects, 'tasks' => $tasks]; } /** * 项目风险(RAG):取该项目最新一条评论的状态(green/yellow/red)。 */ private function projectRisk(int $projectId): string { try { $comments = $this->commentRepo->getComments('project', $projectId, 1); if (! empty($comments)) { $status = strtolower((string) ($comments[0]['status'] ?? '')); if (in_array($status, ['green', 'yellow', 'red'], true)) { return $status; } } } catch (\Throwable $e) { // ignore } return 'none'; } /** * 风险严重度:red > yellow > green > none,用于父项目聚合时取最严重。 */ private function riskSeverity(string $risk): int { return match (strtolower($risk)) { 'red' => 3, 'yellow' => 2, 'green' => 1, default => 0, }; } /** * Finds projects based on a search term. * * @param string $term The search term (optional) * @return array The filtered projects that match the search term * * @api */ public function findProject(string $term = '') { $projects = $this->projectRepository->getUserProjects( userId: session('userdata.id'), accessStatus: 'all', projectTypes: 'project'); $filteredProjects = []; foreach ($projects as $key => $project) { if (Str::contains($projects[$key]['name'], $term, ignoreCase: true) || $term == '') { $projects[$key] = $this->prepareDatesForApiResponse($project); $projects[$key]['id'] = $project['id'].'-'.$project['modified']; $filteredProjects[] = $projects[$key]; } } return $filteredProjects; } /** * Polls for new projects for the current user session. * Retrieves all projects for the current user and prepares the dates for API response. * * @return array An array of projects with prepared dates for API response. * * @api */ public function pollForNewProjects() { $projects = $this->projectRepository->getUserProjects(userId: session('userdata.id'), accessStatus: 'all'); foreach ($projects as $key => $project) { $projects[$key] = $this->prepareDatesForApiResponse($project); } return $projects; } /** * Polls for updated projects. * Retrieves all the projects the current user has access to and prepares them for API response. * Adds the modified timestamp to the project ID for tracking updates. * * * @api */ public function pollForUpdatedProjects(): array { $projects = $this->projectRepository->getUserProjects(userId: session('userdata.id'), accessStatus: 'all'); foreach ($projects as $key => $project) { $projects[$key] = $this->prepareDatesForApiResponse($project); $projects[$key]['id'] = $project['id'].'-'.$project['modified']; } return $projects; } /** * Returns the list of supported menu types. * * Thin passthrough so controllers do not have to inject the menu repository. * * @return array Map of menu type key => translated label. * * @api */ public function getMenuTypes(): array { return app()->make(MenuRepository::class)->getMenuTypes(); } /** * Returns all users in the system. * * Thin passthrough so controllers do not have to inject the user repository. * * @param bool $activeOnly When true only active users are returned. * @return array List of users. * * @api */ public function getAllUsers(bool $activeOnly = false): array { return $this->userRepo->getAll($activeOnly); } /** * Returns all users flagged as employees. * * Thin passthrough so controllers do not have to inject the user repository. * * @return array List of employee users. * * @api */ public function getEmployees(): array { return $this->userRepo->getEmployees(); } /** * Builds the default value set used to render the new-project form. * * @param string $parent Optional parent project id pre-fill. * @return array Default project value structure. * * @api */ public function getNewProjectDefaults(string $parent = ''): array { return [ 'id' => '', 'name' => '', 'details' => '', 'clientId' => '', 'hourBudget' => '', 'assignedUsers' => [session('userdata.id')], 'dollarBudget' => '', 'state' => '', 'menuType' => MenuRepository::DEFAULT_MENU, 'type' => 'project', 'parent' => $parent, 'psettings' => '', 'start' => '', 'end' => '', ]; } /** * Notifies assigned project users that a new project was created. * * Loads the project's assigned users, filters them by their notification * preference, builds the localized email body and queues the message. * * @param int $projectId The newly created project id. * @param string $projectName The project name (used in the message body). * @param string $authorName The display name of the user who created the project. * * @api */ public function notifyProjectCreated(int $projectId, string $projectName, string $authorName): void { $users = $this->getUsersAssignedToProject($projectId); $actualLink = BASE_URL.'/projects/showProject/'.$projectId; $message = sprintf( $this->language->__('email_notifications.project_created_message'), $actualLink, $projectId, strip_tags($projectName), $authorName ); $to = []; foreach ($users as $user) { if ($user['notifications'] != 0) { $to[] = $user['username']; } } $this->queueRepo->queueMessageToUsers( $to, $message, $this->language->__('email_notifications.project_created_subject'), $projectId ); } /** * Builds the data needed to render the project hub for a user. * * Collects the user's open projects, derives the unique client map across * all of them and filters the displayed projects by the optionally selected * client. Used by both the standard and the HTMX project-hub endpoints. * * @param int $userId The user whose projects should be loaded. * @param int|null $clientId Optional client id to filter the project list. * @return array{allProjects: array, clients: array, currentClientName: string, currentClient: int|string} * * @api */ public function getProjectHubData(int $userId, ?int $clientId = null): array { $userId = $this->resolveScopedUserId($userId); $currentClientName = ''; $currentClient = $clientId ?? ''; if (! empty($clientId)) { $client = $this->clientRepo->getClient($clientId); if (is_array($client) && count($client) > 0) { $currentClientName = $client['name']; } } $allProjects = $this->getProjectsAssignedToUser($userId, 'open'); $clients = []; $projectResults = []; $i = 0; if (is_array($allProjects)) { foreach ($allProjects as $project) { if (! array_key_exists($project['clientId'], $clients)) { $clients[$project['clientId']] = ['name' => $project['clientName'], 'id' => $project['clientId']]; } if (empty($clientId) || $project['clientId'] == $clientId) { $projectResults[$i] = $project; $i++; } } } return [ 'allProjects' => $projectResults, 'clients' => $clients, 'currentClientName' => $currentClientName, 'currentClient' => $currentClient, ]; } /** * Builds the progress view-model for a single project card. * * Combines the project's completion progress, assigned team and most recent * project comment (used as the "last update" / status) into one structure * for the project-card progress bar partial. * * @param int $projectId The project id to assemble card data for. * @return array The project card data including id, progress, team, lastUpdate and status. * * @api */ #[RequiresPermission(ProjectsPermissions::VIEW, projectIdParam: 'projectId')] public function getProjectCardData(int $projectId): array { $project = ['id' => $projectId]; $project['progress'] = $this->getProjectProgress($projectId); $project['team'] = $this->getUsersAssignedToProject($projectId); $projectComments = $this->commentRepo->getComments('project', $projectId); if (is_array($projectComments) && count($projectComments) > 0) { $project['lastUpdate'] = $projectComments[0]; $project['status'] = $projectComments[0]['status']; } else { $project['lastUpdate'] = false; $project['status'] = ''; } return $project; } /** * Persists the Mattermost webhook for a project. * * @param int $projectId The project id. * @param string $webhookUrl The raw webhook URL from the request. * * @api */ #[RequiresPermission(ProjectsPermissions::EDIT, global: true)] public function saveMattermostWebhook(int $projectId, string $webhookUrl): void { $this->saveProjectSetting($projectId, 'mattermostWebhookURL', strip_tags($webhookUrl)); } /** * Persists the Slack webhook for a project. * * @param int $projectId The project id. * @param string $webhookUrl The raw webhook URL from the request. * * @api */ #[RequiresPermission(ProjectsPermissions::EDIT, global: true)] public function saveSlackWebhook(int $projectId, string $webhookUrl): void { $this->saveProjectSetting($projectId, 'slackWebhookURL', strip_tags($webhookUrl)); } /** * Validates and persists the Zulip webhook configuration for a project. * * All five fields are required. The sanitized hook is returned so the * caller can re-render the form with the submitted values. * * @param int $projectId The project id. * @param array $hookData Raw hook fields (zulipURL, zulipEmail, zulipBotKey, zulipStream, zulipTopic). * @return array{hook: array, saved: bool} The sanitized hook and whether it was persisted. * * @api */ #[RequiresPermission(ProjectsPermissions::EDIT, global: true)] public function saveZulipWebhook(int $projectId, array $hookData): array { $zulipHook = [ 'zulipURL' => strip_tags($hookData['zulipURL'] ?? ''), 'zulipEmail' => strip_tags($hookData['zulipEmail'] ?? ''), 'zulipBotKey' => strip_tags($hookData['zulipBotKey'] ?? ''), 'zulipStream' => strip_tags($hookData['zulipStream'] ?? ''), 'zulipTopic' => strip_tags($hookData['zulipTopic'] ?? ''), ]; $saved = false; if ( $zulipHook['zulipURL'] != '' && $zulipHook['zulipEmail'] != '' && $zulipHook['zulipBotKey'] != '' && $zulipHook['zulipStream'] != '' && $zulipHook['zulipTopic'] != '' ) { $this->saveProjectSetting($projectId, 'zulipHook', serialize($zulipHook)); $saved = true; } return ['hook' => $zulipHook, 'saved' => $saved]; } /** * Validates and persists the Telegram bot configuration for a project. * * Requires a bot token. If no chat id is supplied, calls Telegram's getUpdates * API to auto-detect the most recent chat that has messaged the bot. If a chat * id is supplied directly (group/topic mode), it is used as-is and no API call * is made. * * @param int $projectId The project id. * @param array $hookData Raw hook fields (telegramBotToken, telegramChatId, telegramTopicId). * @return array{hook: array, saved: bool, error: string|null} * * @api */ #[RequiresPermission(ProjectsPermissions::EDIT, global: true)] public function saveTelegramWebhook(int $projectId, array $hookData): array { $rawTopicId = trim(strip_tags($hookData['telegramTopicId'] ?? '')); $telegramTopicId = (is_numeric($rawTopicId) && (int) $rawTopicId > 0) ? (string) (int) $rawTopicId : ''; $telegramHook = [ 'telegramBotToken' => trim(strip_tags($hookData['telegramBotToken'] ?? '')), 'telegramChatId' => trim(strip_tags($hookData['telegramChatId'] ?? '')), 'telegramTopicId' => $telegramTopicId, ]; if ($telegramHook['telegramBotToken'] === '') { return ['hook' => $telegramHook, 'saved' => false, 'error' => 'missing_token']; } if ($telegramHook['telegramChatId'] === '') { $detected = $this->detectTelegramChatId($telegramHook['telegramBotToken']); if ($detected === null) { return ['hook' => $telegramHook, 'saved' => false, 'error' => 'chat_not_found']; } $telegramHook['telegramChatId'] = $detected['chatId']; if ($telegramHook['telegramTopicId'] === '' && ! empty($detected['topicId'])) { $telegramHook['telegramTopicId'] = (string) $detected['topicId']; } } $this->saveProjectSetting($projectId, 'telegramHook', serialize($telegramHook)); return ['hook' => $telegramHook, 'saved' => true, 'error' => null]; } /** * Calls Telegram's getUpdates API and returns the detected chat id and topic id (if present) * of the most recent message sent to the bot, or null if none is found / the call fails. * * Internal helper for saveTelegramWebhook(), which carries the permission gate. Kept private * so it stays off the JSON-RPC surface: it makes the server issue an outbound request with a * caller-supplied token, which is not something to expose as an @api method. */ private function detectTelegramChatId(string $botToken): ?array { try { $response = $this->httpClient->get( "https://api.telegram.org/bot{$botToken}/getUpdates", [ 'allow_redirects' => OutboundUrlGuard::redirectOptions(), 'connect_timeout' => 5, 'timeout' => 10, 'query' => ['limit' => 100], ] ); $body = json_decode((string) $response->getBody(), true); $result = is_array($body) ? ($body['result'] ?? []) : []; if (is_array($result)) { foreach (array_reverse($result) as $update) { if (is_array($update) && isset($update['message']['chat']['id'])) { $chatId = $update['message']['chat']['id']; $topicId = $update['message']['message_thread_id'] ?? null; return [ 'chatId' => (string) $chatId, 'topicId' => $topicId !== null ? (string) $topicId : null, ]; } } } return null; } catch (\Throwable $e) { Log::warning('Telegram getUpdates failed', ['exception' => get_class($e)]); return null; } } /** * Persists the (up to three) Discord webhooks for a project. * * @param int $projectId The project id. * @param array $postData The raw request data containing discordWebhookURL1..3. * * @api */ #[RequiresPermission(ProjectsPermissions::EDIT, global: true)] public function saveDiscordWebhooks(int $projectId, array $postData): void { for ($i = 1; $i <= 3; $i++) { $webhook = trim(strip_tags($postData['discordWebhookURL'.$i] ?? '')); $this->saveProjectSetting($projectId, 'discordWebhookURL'.$i, $webhook); } } /** * Loads and de-serializes the integration webhook settings for a project. * * Returns the Mattermost and Slack URLs, the three Discord URLs and the * (safely unserialized) Zulip hook configuration ready for the template. * * @param int $projectId The project id. * @return array The integration settings keyed by template variable name. * * @api */ #[RequiresPermission(ProjectsPermissions::VIEW, projectIdParam: 'projectId')] public function getProjectIntegrationSettings(int $projectId): array { $settings = [ 'mattermostWebhookURL' => $this->getProjectSetting($projectId, 'mattermostWebhookURL'), 'slackWebhookURL' => $this->getProjectSetting($projectId, 'slackWebhookURL'), ]; for ($i = 1; $i <= 3; $i++) { $settings['discordWebhookURL'.$i] = $this->getProjectSetting($projectId, 'discordWebhookURL'.$i); } $zulipWebhook = $this->getProjectSetting($projectId, 'zulipHook'); if ($zulipWebhook == '') { $settings['zulipHook'] = [ 'zulipURL' => '', 'zulipEmail' => '', 'zulipBotKey' => '', 'zulipStream' => '', 'zulipTopic' => '', ]; } else { $settings['zulipHook'] = safe_unserialize($zulipWebhook, []); } $telegramHook = $this->getProjectSetting($projectId, 'telegramHook'); if ($telegramHook == '') { $settings['telegramHook'] = [ 'telegramBotToken' => '', 'telegramChatId' => '', 'telegramTopicId' => '', ]; } else { $settings['telegramHook'] = safe_unserialize($telegramHook, []); } return $settings; } /** * Edits a project and notifies its users about the update. * * Persists the project changes via editProject and then assembles and * dispatches the "project updated" notification to the project's users. * * @param array $values The project values to persist. * @param int $projectId The project id being edited. * @param array $project The current project entity (used in the notification). * @param string $currentUrl The current request URL (used as notification CTA target). * @param int $authorId The id of the user performing the edit. * @param string $authorName The display name of the user performing the edit. * * @api */ #[RequiresPermission(ProjectsPermissions::EDIT, global: true)] public function editProjectAndNotify( array $values, int $projectId, array $project, string $currentUrl, int $authorId, string $authorName ): void { $this->editProject($values, $projectId); $subject = sprintf($this->language->__('email_notifications.project_update_subject'), $projectId, $values['name']); $message = sprintf( $this->language->__('email_notifications.project_update_message'), $authorName, strip_tags($values['name']) ); $notification = app()->make(Notification::class); $notification->url = [ 'url' => $currentUrl, 'text' => $this->language->__('email_notifications.project_update_cta'), ]; $notification->entity = $project; $notification->module = 'projects'; $notification->action = 'updated'; $notification->projectId = session('currentProject'); $notification->subject = $subject; $notification->authorId = $authorId; $notification->message = $message; $this->notifyProjectUsers($notification); } /** * Prepares date values in a project for API response. * * The method takes a project array and converts the 'modified', 'start', * and 'end' date values into ISO 8601 Zulu string format. If a date value * is not a valid string, it sets it to null. * * @param array $project The project array to be modified. * @return array The modified project array with formatted date values. * * @internal */ private function prepareDatesForApiResponse($project) { if (dtHelper()->isValidDateString($project['modified'])) { $project['modified'] = dtHelper()->parseDbDateTime($project['modified'])->toIso8601ZuluString(); } else { $project['modified'] = null; } if (dtHelper()->isValidDateString($project['start'])) { $project['start'] = dtHelper()->parseDbDateTime($project['start'])->toIso8601ZuluString(); } else { $project['start'] = null; } if (dtHelper()->isValidDateString($project['end'])) { $project['end'] = dtHelper()->parseDbDateTime($project['end'])->toIso8601ZuluString(); } else { $project['end'] = null; } return $project; } }