3644 lines
138 KiB
PHP
3644 lines
138 KiB
PHP
<?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 数组。
|
||
*
|
||
* 每个项目一条 task:id / 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;
|
||
|
||
}
|
||
}
|