appSettings = $appSettings; $this->config = $config; $this->projectRepository = $projectRepository; $this->sprintRepository = $sprintRepository; $this->reportRepository = $reportRepository; $this->settings = $settings; $this->ticketRepository = $ticketRepository; $this->sprintService = $sprintService; } /** * Resolves which sprint burndown to display on the reports page. * * Mirrors the legacy controller selection order exactly: * 1. An explicitly requested sprint id (from the query string). * 2. Otherwise the project's current sprint. * 3. Otherwise the first available sprint. * * The returned 'currentSprintId' preserves the original behaviour: * when a sprint id is explicitly requested it is echoed back as-is * (even if the sprint cannot be loaded); when falling back to the * current/first sprint the resolved sprint object's id is used. * When the project has no sprints at all, both values are false. * * @param int $projectId Project to resolve the burndown for. * @param int|null $requestedSprintId Sprint id explicitly requested by the user, or null. * @return array{chart: false|array, currentSprintId: int|false} Burndown chart data and the resolved sprint id. * * @api */ #[RequiresPermission(ReportsPermissions::VIEW, projectIdParam: 'projectId')] public function getSprintBurndownForReport(int $projectId, ?int $requestedSprintId): array { $allSprints = $this->sprintService->getAllSprints($projectId); if (count($allSprints) === 0) { return ['chart' => false, 'currentSprintId' => false]; } $sprintChart = false; if ($requestedSprintId !== null) { $sprintObject = $this->sprintService->getSprint($requestedSprintId); if ($sprintObject) { $sprintChart = $this->sprintService->getSprintBurndown($sprintObject); } return ['chart' => $sprintChart, 'currentSprintId' => $requestedSprintId]; } $currentSprint = $this->sprintService->getCurrentSprintId($projectId); if ($currentSprint !== false && $currentSprint !== 'all') { $sprintObject = $this->sprintService->getSprint((int) $currentSprint); if ($sprintObject) { $sprintChart = $this->sprintService->getSprintBurndown($sprintObject); return ['chart' => $sprintChart, 'currentSprintId' => $sprintObject->id]; } return ['chart' => $sprintChart, 'currentSprintId' => false]; } $sprintChart = $this->sprintService->getSprintBurndown($allSprints[0]); return ['chart' => $sprintChart, 'currentSprintId' => $allSprints[0]->id]; } /** * Runs the daily report ingestion for the session's current project. * * Not @api: an internal web-path helper (called by the gated Reports\Controllers\Show after * dispatch enforcement). It reads session('currentProject'), so an RPC caller would have no * meaningful project binding — and it was needlessly RPC-exposed before. * * @throws BindingResolutionException */ public function dailyIngestion(): void { $this->runIngestionForProject(session('currentProject')); } protected function runIngestionForProject(int $projectId): void { if (Cache::has('dailyReports-'.$projectId) === false || Cache::get('dailyReports-'.$projectId) < dtHelper()->dbNow()->endOfDay()) { // Check if the dailyingestion cycle was executed already. There should be one entry for backlog and one entry for current sprint (unless there is no current sprint // Get current Sprint Id, if no sprint available, dont run the sprint burndown $lastEntries = $this->reportRepository->checkLastReportEntries($projectId); // If we receive 2 entries we have a report already. If we have one entry then we ran the backlog one and that means there was no current sprint. if (count($lastEntries) == 0) { $currentSprint = $this->sprintRepository->getCurrentSprint($projectId); if ($currentSprint !== false) { $sprintReport = $this->reportRepository->runTicketReport($projectId, $currentSprint->id); if ($sprintReport !== false) { $this->reportRepository->addReport($sprintReport); } } $backlogReport = $this->reportRepository->runTicketReport($projectId, ''); if ($backlogReport !== false) { $this->reportRepository->addReport($backlogReport); Cache::put('dailyReports-'.$projectId, dtHelper()->dbNow()->endOfDay(), 14400); // 4hours } } } } public function cronDailyIngestion(): void { $projects = $this->projectRepository->getAll(); foreach ($projects as $project) { $this->runIngestionForProject($project['id']); } } /** * Returns a project's stored report history, authorized against the requested project. * * $projectId is typed int so the param is REQUIRED and non-null: a JSON-RPC caller cannot * pass null to make PermissionEnforcer::resolveProjectId() fall back to the session project * (its isset() check treats explicit null as absent), which would dodge the per-target gate. * * @api */ #[RequiresPermission(ReportsPermissions::VIEW, projectIdParam: 'projectId')] public function getFullReport(int $projectId): false|array { return $this->reportRepository->getFullReport($projectId); } /** * Computes a project's current ticket report, authorized against the requested project. * The repository scopes by BOTH projectId and sprint, so a foreign sprint id yields no rows. * * $projectId is typed int for the same reason as getFullReport() — it keeps the dispatch gate * bound to the requested project (no null → session fallback). $sprintId stays mixed because * the empty string is the meaningful "backlog" selector. * * @throws BindingResolutionException * * @api */ #[RequiresPermission(ReportsPermissions::VIEW, projectIdParam: 'projectId')] public function getRealtimeReport(int $projectId, $sprintId): array|bool { return $this->reportRepository->runTicketReport($projectId, $sprintId); } /** * Builds the anonymous telemetry payload (instance-wide usage counts + company GUID). * * Not @api: system-level data source for sendAnonymousTelemetry() only. Exposing it over * JSON-RPC let any authenticated user read instance-wide aggregates (user/project/feature * counts) — reconnaissance, not a user capability. */ public function getAnonymousTelemetry( IdeaRepository $ideaRepository, UserRepository $userRepository, ClientRepository $clientRepository, CommentRepository $commentsRepository, TimesheetRepository $timesheetRepo, BlueprintsRepository $blueprintsRepo ): array { // Get anonymous company guid $companyId = $this->settings->getCompanyId(); self::dispatch_event('beforeTelemetrySend', ['companyId' => $companyId]); $companyLang = $this->settings->getSetting('companysettings.language'); if ($companyLang != '' && $companyLang !== false) { $currentLanguage = $companyLang; } else { $currentLanguage = $this->config->language; } $projectStatusCount = $this->getProjectStatusReport(); $taskSentiment = $this->generateTicketReactionsReport(); $telemetry = [ 'date' => '', 'companyId' => $companyId, 'env' => 'oss', 'version' => $this->appSettings->appVersion, 'language' => $currentLanguage, 'numUsers' => $userRepository->getNumberOfUsers(), 'lastUserLogin' => $userRepository->getLastLogin(), 'numProjects' => $this->projectRepository->getNumberOfProjects(null, 'project'), 'numProjectsGreen' => $projectStatusCount['green'] ?? 0, 'numProjectsYellow' => $projectStatusCount['yellow'] ?? 0, 'numProjectsRed' => $projectStatusCount['red'] ?? 0, 'numProjectsNone' => $projectStatusCount['none'] ?? 0, 'numStrategies' => $this->projectRepository->getNumberOfProjects(null, 'strategy'), 'numPrograms' => $this->projectRepository->getNumberOfProjects(null, 'program'), 'numClients' => $clientRepository->getNumberOfClients(), 'numComments' => $commentsRepository->countComments(), 'numMilestones' => $this->ticketRepository->getNumberOfMilestones(), 'numTickets' => $this->ticketRepository->getNumberOfAllTickets(), 'numBoards' => $ideaRepository->getNumberOfBoards(), 'numIdeaItems' => $ideaRepository->getNumberOfIdeas(), 'numHoursBooked' => $timesheetRepo->getHoursBooked(), 'numResearchBoards' => $blueprintsRepo->getNumberOfBoards(null, 'leancanvas'), 'numResearchItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'leancanvas'), 'numRetroBoards' => $blueprintsRepo->getNumberOfBoards(null, 'retroscanvas'), 'numRetroItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'retroscanvas'), 'numGoalBoards' => $blueprintsRepo->getNumberOfBoards(null, 'goalcanvas'), 'numGoalItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'goalcanvas'), 'numValueCanvasBoards' => $blueprintsRepo->getNumberOfBoards(null, 'valuecanvas'), 'numValueCanvasItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'valuecanvas'), 'numMinEmpathyBoards' => $blueprintsRepo->getNumberOfBoards(null, 'minempathycanvas'), 'numMinEmpathyItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'minempathycanvas'), 'numOBMBoards' => $blueprintsRepo->getNumberOfBoards(null, 'obmcanvas'), 'numOBMItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'obmcanvas'), 'numSWOTBoards' => $blueprintsRepo->getNumberOfBoards(null, 'swotcanvas'), 'numSWOTItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'swotcanvas'), 'numSBBoards' => $blueprintsRepo->getNumberOfBoards(null, 'sbcanvas'), 'numSBItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'sbcanvas'), 'numRISKSBoards' => $blueprintsRepo->getNumberOfBoards(null, 'riskscanvas'), 'numRISKSItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'riskscanvas'), 'numEABoards' => $blueprintsRepo->getNumberOfBoards(null, 'eacanvas'), 'numEAItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'eacanvas'), 'numINSIGHTSBoards' => $blueprintsRepo->getNumberOfBoards(null, 'insightscanvas'), 'numINSIGHTSItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'insightscanvas'), 'numWikiBoards' => $blueprintsRepo->getNumberOfBoards(null, 'wiki'), 'numWikiItems' => $blueprintsRepo->getNumberOfCanvasItems(null, 'wiki'), 'numTaskSentimentAngry' => $taskSentiment['🤬'] ?? 0, 'numTaskSentimentDisgust' => $taskSentiment['🤢'] ?? 0, 'numTaskSentimentUnhappy' => $taskSentiment['🙁'] ?? 0, 'numTaskSentimentNeutral' => $taskSentiment['😐'] ?? 0, 'numTaskSentimentHappy' => $taskSentiment['🙂'] ?? 0, 'numTaskSentimentLove' => $taskSentiment['😍'] ?? 0, 'numTaskSentimenUnicorn' => $taskSentiment['🦄'] ?? 0, 'serverSoftware' => $_SERVER['SERVER_SOFTWARE'] ?? 'unknown', 'phpUname' => php_uname(), 'isDocker' => $this->isRunningInDocker(), 'phpSapiName' => php_sapi_name(), 'phpOs' => PHP_OS, ]; $telemetry = self::dispatch_filter('beforeReturnTelemetry', $telemetry); return $telemetry; } /** * Sends the daily anonymous telemetry ping (throttled to once a day, opt-out respected). * * Not @api: a system task invoked by the scheduler (register.php cron, no session user) and * the dashboard's lazy trigger — never a user-invokable RPC method (it makes an outbound * HTTP request). * * @throws BindingResolutionException */ public function sendAnonymousTelemetry(): bool|PromiseInterface { // Only send once a day $allowTelemetry = app('config')->allowTelemetry ?? true; if ($allowTelemetry === true) { $date_utc = new DateTime('now', new DateTimeZone('UTC')); $today = $date_utc->format('Y-m-d'); $lastUpdate = $this->settings->getSetting('companysettings.telemetry.lastUpdate'); if ($lastUpdate != $today) { $telemetry = app()->call([$this, 'getAnonymousTelemetry']); $telemetry['date'] = $today; // Do the curl $httpClient = new Client; try { $data_string = json_encode($telemetry); $promise = $httpClient->postAsync('https://telemetry.leantime.io', [ 'form_params' => [ 'telemetry' => $data_string, ], // Short connect timeout so an offline/air-gapped server (or a // CI runner with no egress) fails fast instead of blocking the // dashboard's Welcome widget — and saturating PHP-FPM workers — // for minutes. The previous 480s total timeout hung the page // when telemetry was unreachable. (#3372/#3373) 'connect_timeout' => 2, 'timeout' => 5, ])->then(function ($response) use ($today) { $this->settings->saveSetting('companysettings.telemetry.lastUpdate', $today); }); return $promise; } catch (\Exception $e) { Log::error($e); return false; } } } return false; } /** * Opts the whole instance out of telemetry (writes companysettings.telemetry.active=false). * * Not @api: an instance-wide settings MUTATION. It is invoked internally by the admin-gated * company-settings save (Setting::saveCompanySettings) — over JSON-RPC it previously let ANY * authenticated user flip the company-wide telemetry setting. * * @return false|void * * @throws Exception */ public function optOutTelemetry() { $date_utc = new DateTime('now', new DateTimeZone('UTC')); $today = $date_utc->format('Y-m-d'); $companyId = $this->settings->getCompanyId(); $telemetry = [ 'date' => '', 'companyId' => $companyId, 'version' => $this->appSettings->appVersion, 'language' => '', 'numUsers' => 0, 'lastUserLogin' => 0, 'numProjects' => 0, 'numClients' => 0, 'numComments' => 0, 'numMilestones' => 0, 'numTickets' => 0, 'numBoards' => 0, 'numIdeaItems' => 0, 'numHoursBooked' => 0, 'numResearchBoards' => 0, 'numResearchItems' => 0, 'numRetroBoards' => 0, 'numRetroItems' => 0, 'numGoalBoards' => 0, 'numGoalItems' => 0, 'numValueCanvasBoards' => 0, 'numValueCanvasItems' => 0, 'numMinEmpathyBoards' => 0, 'numMinEmpathyItems' => 0, 'numOBMBoards' => 0, 'numOBMItems' => 0, 'numSWOTBoards' => 0, 'numSWOTItems' => 0, 'numSBBoards' => 0, 'numSBItems' => 0, 'numRISKSBoards' => 0, 'numRISKSItems' => 0, 'numEABoards' => 0, 'numEAItems' => 0, 'numINSIGHTSBoards' => 0, ]; $telemetry['date'] = $today; // Do the curl $httpClient = new Client; try { $data_string = json_encode($telemetry); $promise = $httpClient->postAsync('https://telemetry.leantime.io', [ 'form_params' => [ 'telemetry' => $data_string, ], 'timeout' => 5, ])->then(function ($response) use ($today) { $this->settings->saveSetting('companysettings.telemetry.lastUpdate', $today); session(['skipTelemetry' => true]); }); } catch (\Exception $e) { report($e); session(['skipTelemetry' => true]); return false; } $this->settings->saveSetting('companysettings.telemetry.active', false); session(['skipTelemetry' => true]); try { $promise->wait(); } catch (\Exception $e) { report($e); } } /** * Counts ALL projects in the instance by status color — a telemetry data source. * * Not @api: instance-wide aggregation with no project scope; over JSON-RPC it disclosed the * whole company's project-health summary to any authenticated user. * * @return array * * @throws Exception */ public function getProjectStatusReport() { $projectStatus = $this->projectRepository->getAll(); $statusList = ['green' => 0, 'yellow' => 0, 'red' => 0, 'none' => 0]; foreach ($projectStatus as $project) { if (isset($statusList[$project['status']])) { $statusList[$project['status']]++; } else { $statusList['none']++; } } return $statusList; } public function generateTicketReactionsReport() { $reactionsRepo = app()->make(Reactions::class); $collectedReactions = $reactionsRepo->getReactionsByModule('ticketSentiment'); $reactions = [ '🤬' => 0, '🤢' => 0, '🙁' => 0, '😐' => 0, '🙂' => 0, '😍' => 0, '🦄' => 0, 'other' => 0, ]; foreach ($collectedReactions as $reaction) { if (isset($reactions[$reaction['reaction']])) { $reactions[$reaction['reaction']] = $reactions[$reaction['reaction']] + $reaction['reactionCount']; } } return $reactions; } /** * Checks if Leantime is running in a Docker environment * Uses multiple detection methods and handles errors gracefully */ private function isRunningInDocker(): bool { // Method 1: Check for /.dockerenv file try { if (is_file('/.dockerenv')) { return true; } } catch (\Exception $e) { // Silently fail if file access is restricted } // Method 2: Check for Docker-specific environment variables if (getenv('DOCKER_CONTAINER') !== false || getenv('IS_DOCKER') !== false) { return true; } // Method 3: Check cgroup info (works on Linux hosts) try { return strpos(file_get_contents('/proc/1/cgroup'), 'docker') !== false; } catch (\Exception $e) { return false; // Return false if all detection methods fail } } }