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

3644 lines
138 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Leantime\Domain\Projects\Services;
use DateInterval;
use DateTime;
use GuzzleHttp\Client;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Leantime\Core\Auth\Contracts\ChecksProjectAccess;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Domains\BaseService;
use Leantime\Core\Events\EventDispatcher as EventCore;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Core\Exceptions\NotFoundException;
use Leantime\Core\Language as LanguageCore;
use Leantime\Core\Support\Avatarcreator;
use Leantime\Core\Support\FromFormat;
use Leantime\Core\Support\OutboundUrlGuard;
use Leantime\Domain\Auth\Models\Roles;
use Leantime\Domain\Auth\Services\Auth;
use Leantime\Domain\Blueprints\Repositories\Blueprints as BlueprintsRepository;
use Leantime\Domain\Clients\Repositories\Clients as ClientRepository;
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
use Leantime\Domain\Files\Services\Files;
use Leantime\Domain\Goalcanvas\Repositories\Goalcanvas as GoalcanvaRepository;
use Leantime\Domain\Ideas\Repositories\Ideas as IdeaRepository;
use Leantime\Domain\Menu\Repositories\Menu as MenuRepository;
use Leantime\Domain\Notifications\Models\Notification;
use Leantime\Domain\Notifications\Services\Messengers;
use Leantime\Domain\Notifications\Services\Notifications as NotificationService;
use Leantime\Domain\Projects\Permissions\ProjectsPermissions;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Leantime\Domain\Queue\Repositories\Queue as QueueRepository;
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketRepository;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use Leantime\Domain\Wiki\Repositories\Wiki;
use SVG\SVG;
use Symfony\Component\HttpFoundation\Response;
/**
* Projects service (the company project god-service) + the engine's project-access provider.
*
* AUTHORIZATION MODEL (two scopes — see {@see ProjectsPermissions}):
* - by-id READS carry a dispatch #[RequiresPermission(projects.view, projectIdParam: ...)] gate
* (readonly+, AND-ins data access) — closes the cross-project read IDOR on the RPC surface.
* - MUTATIONS carry #[RequiresPermission(projects.create|edit|delete, global: true)] (manager+,
* company-wide — grant-equivalent to the legacy forceGlobal manager controllers).
* - $userId-param reads pin to the session user (admin may query others), mirroring
* getProjectsUserHasAccessTo().
*
* ⚠️ RECURSION GUARDRAIL: this class implements {@see ChecksProjectAccess}; the permission engine
* calls getProjectRole() and isUserAssignedToProject() during EVERY project-scoped authorization.
* Those two methods — and userCanManageProject()/getProjectsUserHasAccessTo() which the engine path
* also touches — MUST NEVER call $this->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<string, 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 .= " <a href='".$notification->url['url']."'>".$notification->url['text'].'</a>';
}
// 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<int> $userIds User IDs to filter.
* @param Notification $notification The notification being dispatched.
* @param array<string, mixed> $preloadedSettings Pre-fetched settings map.
* @return array<int> 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<string, mixed> $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<int> $userIds User IDs to filter.
* @param string $module The notification module value (e.g. 'tickets', 'comments').
* @param array<string, mixed> $preloadedSettings Pre-fetched settings map.
* @return array<int> 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<int> 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<int> 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<int, mixed> $recipients The current recipient user IDs.
* @param array<int, int> $bypassUserIds User IDs that should always receive the notification.
* @param mixed $authorId The notification author, excluded from the result.
* @return array<int, mixed> 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<int|string, true> $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 "<slug>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 数组。
*
* 每个项目一条 taskid / name含负责人 + 风险 badge 的 HTML/ start / end /
* progress / dependencies子项目指向父项目 id。父项目进度 = 子项目进度算术平均,
* 风险 = 子树内最严重red > yellow > green随子项目联动。
*
* @param array $projects 已查询好的项目列表(与 ShowAll 控制器同一来源,避免重复查询)
* @return array{projects:array<int,array>,tasks:array<int,array<string,mixed>>}
*/
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 = '<b>'.htmlspecialchars((string) $p['name'], ENT_QUOTES).'</b>'
.($ownerLabel !== '' ? ' <span style="color:#64748b;font-size:11px;">👤 '.htmlspecialchars($ownerLabel, ENT_QUOTES).'</span>' : '')
.' <span style="display:inline-block;padding:0 6px;border-radius:3px;font-size:11px;color:#fff;background:'.$riskColor.';">'.$riskLabel.'</span>';
$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;
}
}