|null Memoized accessibleProjectIds() result. */ private ?array $accessibleProjectIdsCache = null; public array $reportingSettings = [ 'linkonly', 'linkAndReport', 'nolink', ]; public function __construct(GoalcanvaRepository $goalRepository, ProjectService $projectService) { $this->goalRepository = $goalRepository; $this->projectService = $projectService; } /** * List the goals on a board (by board id), authorized for VIEW against the board's project. * Returns [] for a missing/foreign/unauthorized board (neutral — no oracle). * * @api */ #[RequiresPermission(GoalcanvasPermissions::VIEW, entityScoped: true)] public function getCanvasItemsById(int $id): array { $projectId = $this->goalRepository->getCanvasProjectId($id, self::CANVAS_TYPE); if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) { return []; } $goals = $this->goalRepository->getCanvasItemsById($id); if ($goals) { // Attach each goal's tracked_by milestone chips (one batched read), // so the board/dashboard render the edge model, not the legacy column. $milestonesByGoal = $this->goalRepository->getMilestonesForGoals( array_map(static fn ($g) => (int) $g['id'], $goals) ); foreach ($goals as &$goal) { $goal['milestones'] = $this->filterAccessibleMilestones($milestonesByGoal[(int) $goal['id']] ?? []); $progressValue = 0; $goal['goalProgress'] = 0; $total = $goal['endValue'] - $goal['startValue']; // Skip if start and end are the same (no range to measure). if ($total == 0) { continue; } if ($goal['setting'] == 'linkAndReport') { // GetAll Child elements $currentValueSum = $this->getChildGoalsForReporting($goal['id']); $goal['currentValue'] = $currentValueSum; $progressValue = $currentValueSum - $goal['startValue']; } else { $progressValue = $goal['currentValue'] - $goal['startValue']; } $goal['goalProgress'] = max(0, min(100, round($progressValue / $total, 2) * 100)); } } return $goals; } /** * Sum the linked children's current values for a parent goal, authorized for VIEW against * the PARENT goal's project. The cross-project roll-up of linked children is the feature; * the gate is on the parent the caller asked about. Returns 0 for a missing/foreign/ * unauthorized parent. * * @return int|mixed * * @api */ #[RequiresPermission(GoalcanvasPermissions::VIEW, entityScoped: true)] public function getChildGoalsForReporting($parentId): mixed { $projectId = $this->goalRepository->getCanvasItemProjectId((int) $parentId, self::CANVAS_TYPE); if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) { return 0; } // Goals come back as rows for levl1 and lvl2 being columns, so // goal A | goalChildA // goal A | goalChildB // goal B // Checks if first level is also link+report or just link $goals = $this->goalRepository->getCanvasItemsByKPI($parentId); $currentValueSum = 0; foreach ($goals as $child) { if ($child['setting'] == 'linkAndReport') { $currentValueSum = $currentValueSum + $child['childCurrentValue']; } else { $currentValueSum = $currentValueSum + $child['currentValue']; } } return $currentValueSum; } /** * The child-goal hierarchy for a parent goal, authorized for VIEW against the PARENT goal's * project. Returns [] for a missing/foreign/unauthorized parent. * * @api */ #[RequiresPermission(GoalcanvasPermissions::VIEW, entityScoped: true)] public function getChildrenbyKPI($parentId): array { $projectId = $this->goalRepository->getCanvasItemProjectId((int) $parentId, self::CANVAS_TYPE); if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) { return []; } $goals = []; // Goals come back as rows for levl1 and lvl2 being columns, so // goal A | goalChildA // goal A | goalChildB // goal B // Checks if first level is also link+report or just link $children = $this->goalRepository->getCanvasItemsByKPI($parentId); foreach ($children as $child) { // Added Child already? Look for child of child if (! isset($goals[$child['id']])) { $goals[$child['id']] = [ 'id' => $child['id'], 'title' => $child['title'], 'startValue' => $child['startValue'], 'endValue' => $child['endValue'], 'currentValue' => $child['currentValue'], 'metricType' => $child['metricType'], 'boardTitle' => $child['boardTitle'], 'canvasId' => $child['canvasId'], 'projectName' => $child['projectName'], ]; } if ($child['childId'] != '') { if (isset($goals[$child['childId']]) === false) { $goals[$child['childId']] = [ 'id' => $child['childId'], 'title' => $child['childTitle'], 'startValue' => $child['childStartValue'], 'endValue' => $child['childEndValue'], 'currentValue' => $child['childCurrentValue'], 'metricType' => $child['childMetricType'], 'boardTitle' => $child['childBoardTitle'], 'canvasId' => $child['childCanvasId'], 'projectName' => $child['childProjectName'], ]; } } } return $goals; } /** * Available parent KPIs for linking, authorized for VIEW against $projectId (dispatch gate). * * @api */ #[RequiresPermission(GoalcanvasPermissions::VIEW, projectIdParam: 'projectId')] public function getParentKPIs($projectId): array { $kpis = $this->goalRepository->getAllAvailableKPIs($projectId); $goals = []; // Checks if first level is also link+report or just link foreach ($kpis as $kpi) { $goals[$kpi['id']] = [ 'id' => $kpi['id'], 'description' => $kpi['description'], 'project' => $kpi['projectName'], 'board' => $kpi['boardTitle'], ]; } return $goals; } /** * Goals linked to a milestone, authorized for VIEW against the milestone's * project. Reachable beyond the milestone UI (MCP getGoalsByMilestone wraps * it verbatim), so the caller's access to the milestone cannot be assumed — * a missing/foreign/unauthorized milestone returns [] (neutral, no oracle). */ public function getGoalsByMilestone($milestoneId): array { // One cast, used for BOTH the authorization resolve and the read — // authorizing one value and reading another invites drift. $milestoneId = (int) $milestoneId; $projectId = $this->goalRepository->getMilestoneProjectId($milestoneId); if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) { return []; } return $this->goalRepository->getGoalsByMilestone($milestoneId); } /** * The milestone chips for a goal + a status summary, authorized for VIEW * against the goal's project. Chips arrive already sorted (in-progress → * not-started → done, then due date). The summary drives the one-line * roll-up above the chips. * * @return array{milestones: array>, summary: array{total: int, done: int, inProgress: int, notStarted: int}} * * @api */ public function getGoalMilestones(int $goalId): array { $empty = ['total' => 0, 'done' => 0, 'inProgress' => 0, 'notStarted' => 0]; $projectId = $this->goalRepository->getCanvasItemProjectId($goalId, self::CANVAS_TYPE); if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) { return ['milestones' => [], 'summary' => $empty]; } // Same defensive strip the rollup reads apply — keeps the editor chips // consistent with getMilestonesByGoal/getGoalRollup for any legacy // cross-project rows, and the summary counts what is actually shown. $milestones = $this->filterAccessibleMilestones( $this->goalRepository->getMilestonesForGoals([$goalId])[$goalId] ?? [] ); $summary = ['total' => count($milestones)] + $empty; foreach ($milestones as $m) { $type = $m['statusType']; if ($type === 'DONE') { $summary['done']++; } elseif ($type === 'INPROGRESS') { $summary['inProgress']++; } else { $summary['notStarted']++; } } return ['milestones' => $milestones, 'summary' => $summary]; } /** * Batch sibling of getGoalMilestones(): hydrate milestone chips for many * goals in a single pass, so callers with a goal set (e.g. the reports * engine) avoid an N+1. Each goal is authorized for VIEW against its real * project; goals the caller can't see are silently omitted. Returns a map * keyed by goal id: every AUTHORIZED goal is present, mapping to an empty * array when it has no milestones — so callers get a predictable key set. * Only unauthorized/invisible goals are absent. * * @param int[] $goalIds * @return array>> * * @api */ public function getMilestonesForGoals(array $goalIds): array { // Resolve every goal's project in ONE query (not a query per goal), then // authorize per DISTINCT project with a cached VIEW check — so the // auth phase stays O(1) queries regardless of goal-set size. $projectByGoal = $this->goalRepository->getCanvasItemProjectIds($goalIds, self::CANVAS_TYPE); if ($projectByGoal === []) { return []; } $accessByProject = []; $authorized = []; foreach ($projectByGoal as $goalId => $projectId) { if (! array_key_exists($projectId, $accessByProject)) { $accessByProject[$projectId] = $this->can(GoalcanvasPermissions::VIEW, $projectId); } if ($accessByProject[$projectId]) { $authorized[] = (int) $goalId; } } if ($authorized === []) { return []; } // Single hydration pass for the whole authorized set (the expensive part // — status labels + progress — is batched inside the repository). Fill // an empty entry for every authorized goal so an @api caller gets a // predictable key set, not just the goals that happen to have chips. // Each goal's chips get the same defensive cross-project strip as the // single-goal reads (accessibleProjectIds is memoized, so this stays // one projects lookup for the whole batch). return array_replace( array_fill_keys($authorized, []), array_map( fn (array $milestones) => $this->filterAccessibleMilestones($milestones), $this->goalRepository->getMilestonesForGoals($authorized) ) ); } /** * Link one milestone to a goal (append — leaves existing links intact). * Authorized for EDIT against the goal's project. * * @throws AuthorizationException When the goal is unknown/foreign or EDIT is denied. * * @api */ public function addMilestoneToGoal(int $goalId, int $milestoneId): bool { $projectId = $this->goalRepository->getCanvasItemProjectId($goalId, self::CANVAS_TYPE); if ($projectId === null) { throw new AuthorizationException; } $this->authorize(GoalcanvasPermissions::EDIT, $projectId); return $this->goalRepository->addGoalMilestoneLink($goalId, $milestoneId, (int) session('userdata.id')); } /** * Unlink one milestone from a goal (leaves the goal's other links intact). * Authorized for EDIT against the goal's project. * * @throws AuthorizationException When the goal is unknown/foreign or EDIT is denied. * * @api */ public function removeMilestoneFromGoal(int $goalId, int $milestoneId): bool { $projectId = $this->goalRepository->getCanvasItemProjectId($goalId, self::CANVAS_TYPE); if ($projectId === null) { throw new AuthorizationException; } $this->authorize(GoalcanvasPermissions::EDIT, $projectId); return $this->goalRepository->removeGoalMilestoneLink($goalId, $milestoneId); } /** * Cascade: drop every goal's link to a milestone that is being deleted. * Called from the (already-authorized) milestone-delete path. Intentionally * NOT @api — it skips authorization by design, so it must never be reachable * as an unauthenticated JSON-RPC method. */ public function detachMilestoneFromGoals(int $milestoneId): bool { return $this->goalRepository->removeMilestoneFromAllGoals($milestoneId); } /** * A goal's milestones (edge model) for the mobile Progress feature — * authorized for VIEW against the goal's project, and each milestone is * further filtered to the caller's accessible projects as defense-in-depth * (goal→milestone links are same-project only, so normally a no-op). Returns [] * for a missing/foreign/unauthorized goal. Dates are returned as stored * (UTC). A milestone may appear under several goals — this is many-to-many. * * @return array> * * @api */ public function getMilestonesByGoal(int $goalId): array { $projectId = $this->goalRepository->getCanvasItemProjectId($goalId, self::CANVAS_TYPE); if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) { return []; } $milestones = $this->goalRepository->getMilestonesForGoals([$goalId])[$goalId] ?? []; return $this->filterAccessibleMilestones($milestones); } /** * Aggregate rollup for a goal's milestones — the payload the mobile * Progress "arc" is drawn from. Authorized for VIEW against the goal's * project; milestones filtered to the caller's accessible projects. * Computed over the batched milestone read (no N+1). Dates returned as * stored (UTC); `currentMilestoneId` is the first not-done milestone in * the working order (in-progress -> not-started -> done, by due date). * * @return array{goalId: int, total: int, done: int, inProgress: int, notStarted: int, percentComplete: int, startDate: string|null, endDate: string|null, currentMilestoneId: int|null} * * @api */ public function getGoalRollup(int $goalId): array { $empty = [ 'goalId' => $goalId, 'total' => 0, 'done' => 0, 'inProgress' => 0, 'notStarted' => 0, 'percentComplete' => 0, 'startDate' => null, 'endDate' => null, 'currentMilestoneId' => null, ]; $projectId = $this->goalRepository->getCanvasItemProjectId($goalId, self::CANVAS_TYPE); if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) { return $empty; } $milestones = $this->filterAccessibleMilestones( $this->goalRepository->getMilestonesForGoals([$goalId])[$goalId] ?? [] ); if ($milestones === []) { return $empty; } $done = 0; $inProgress = 0; $notStarted = 0; $progressSum = 0; $start = null; $end = null; $current = null; foreach ($milestones as $m) { $type = $m['statusType'] ?? 'NEW'; if ($type === 'DONE') { $done++; } elseif ($type === 'INPROGRESS') { $inProgress++; } else { $notStarted++; } $progressSum += (int) $m['percentDone']; $from = $this->validDate($m['editFrom'] ?? null); $to = $this->validDate($m['editTo'] ?? null); if ($from !== null && ($start === null || $from < $start)) { $start = $from; } if ($to !== null && ($end === null || $to > $end)) { $end = $to; } if ($current === null && $type !== 'DONE') { $current = (int) $m['id']; } } $total = count($milestones); return [ 'goalId' => $goalId, 'total' => $total, 'done' => $done, 'inProgress' => $inProgress, 'notStarted' => $notStarted, 'percentComplete' => (int) round($progressSum / $total), 'startDate' => $start, 'endDate' => $end, 'currentMilestoneId' => $current, ]; } /** * Defensive strip: keep only milestones in projects the caller can access. * Goal→milestone links are same-project only (addGoalMilestoneLink fails * closed on a foreign milestone), so in normal data this is a no-op — it * only guards against any legacy cross-project rows. * * @param array> $milestones * @return array> */ private function filterAccessibleMilestones(array $milestones): array { $accessible = array_flip($this->accessibleProjectIds()); return array_values(array_filter( $milestones, static fn ($m) => isset($accessible[(int) ($m['projectId'] ?? 0)]) )); } /** * Project ids the current user may access. Memoized per request — the * filter now runs per goal in batched reads, and the underlying projects * lookup is expensive. * * @return array */ private function accessibleProjectIds(): array { if ($this->accessibleProjectIdsCache !== null) { return $this->accessibleProjectIdsCache; } $projects = $this->projectService->getProjectsUserHasAccessTo(); if (! is_array($projects)) { return $this->accessibleProjectIdsCache = []; } return $this->accessibleProjectIdsCache = array_values(array_filter( array_map(static fn ($p) => (int) ($p['id'] ?? 0), $projects), static fn ($id) => $id > 0 )); } /** * Return a stored datetime if it's a real value, else null (filters the * '0000-00-00 00:00:00' / empty sentinels milestones can carry). */ private function validDate(mixed $value): ?string { $s = trim((string) $value); if ($s === '' || str_starts_with($s, '0000-00-00')) { return null; } return $s; } // --------------------------------------------------------------------------------------- // Secured by-id board/item CRUD chokepoint (controllers call these instead of the repo). // --------------------------------------------------------------------------------------- /** * Fetch a single goal item by id, authorized for VIEW against the item's real project. * * @return array|false False when missing/foreign/unauthorized. */ public function getGoalItem(int $id): array|false { $projectId = $this->goalRepository->getCanvasItemProjectId($id, self::CANVAS_TYPE); if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) { return false; } $item = $this->goalRepository->getSingleCanvasItem($id); if (is_array($item)) { // Surface the item's REAL (authorized) project so callers scope // project-dependent UI to the goal's project, not the session's — // the dialog can be opened for a goal outside the current project. $item['projectId'] = $projectId; } return $item; } /** * Fetch a single goal board by id, authorized for VIEW against the board's real project. * * @return array|false False when missing/foreign/unauthorized. */ public function getSingleCanvas($id) { $projectId = $this->goalRepository->getCanvasProjectId((int) $id, self::CANVAS_TYPE); if ($projectId === null || ! $this->can(GoalcanvasPermissions::VIEW, $projectId)) { return false; } return $this->goalRepository->getSingleCanvas((int) $id); } /** * Create a goal board, authorized for CREATE against the target project. * * @throws AuthorizationException When projectId is missing or CREATE is denied. */ public function createGoalboard($values) { $projectId = (int) ($values['projectId'] ?? 0); if ($projectId === 0) { throw new AuthorizationException; } $this->authorize(GoalcanvasPermissions::CREATE, $projectId); return $this->goalRepository->addCanvas($values); } /** * Rename a goal board, authorized for EDIT against the board's real project. * * @throws AuthorizationException When the board is unknown/foreign or EDIT is denied. */ public function updateGoalboard($values) { $projectId = $this->goalRepository->getCanvasProjectId((int) ($values['id'] ?? 0), self::CANVAS_TYPE); if ($projectId === null) { throw new AuthorizationException; } $this->authorize(GoalcanvasPermissions::EDIT, $projectId); return $this->goalRepository->updateCanvas($values); } /** * Copy a goal board into a target project. Requires VIEW on the source board's project and * CREATE in the target project. * * @throws AuthorizationException When the source is unknown/foreign, or VIEW/CREATE is denied. */ public function copyGoalBoard(int $sourceCanvasId, int $targetProjectId, int $authorId, string $title): int { $sourceProjectId = $this->goalRepository->getCanvasProjectId($sourceCanvasId, self::CANVAS_TYPE); if ($sourceProjectId === null || $targetProjectId <= 0) { throw new AuthorizationException; } $this->authorize(GoalcanvasPermissions::VIEW, $sourceProjectId); $this->authorize(GoalcanvasPermissions::CREATE, $targetProjectId); return $this->goalRepository->copyCanvas($targetProjectId, $sourceCanvasId, $authorId, $title); } /** * Merge a source goal board's items into a target board. Requires EDIT on the target board's * project and VIEW on the source board's project — both resolved by id. * * @throws AuthorizationException When either board is unknown/foreign, or EDIT/VIEW is denied. */ public function mergeGoalBoard(int $targetCanvasId, int $sourceCanvasId): bool { $targetProjectId = $this->goalRepository->getCanvasProjectId($targetCanvasId, self::CANVAS_TYPE); $sourceProjectId = $this->goalRepository->getCanvasProjectId($sourceCanvasId, self::CANVAS_TYPE); if ($targetProjectId === null || $sourceProjectId === null) { throw new AuthorizationException; } $this->authorize(GoalcanvasPermissions::EDIT, $targetProjectId); $this->authorize(GoalcanvasPermissions::VIEW, $sourceProjectId); return $this->goalRepository->mergeCanvas($targetCanvasId, $sourceCanvasId); } /** * Delete a goal board (and its items), authorized for DELETE against the board's real project. * * @throws AuthorizationException When the board is unknown/foreign or DELETE is denied. */ public function deleteGoalBoard(int $canvasId): void { $projectId = $this->goalRepository->getCanvasProjectId($canvasId, self::CANVAS_TYPE); if ($projectId === null) { throw new AuthorizationException; } $this->authorize(GoalcanvasPermissions::DELETE, $projectId); $this->goalRepository->deleteCanvas($canvasId); } /** * Create a goal item, authorized for CREATE against the target board's real project. * * @param array $values Item values (must include `canvasId`) * @return false|string New item id, or false on insert failure * * @throws AuthorizationException When the target board is unknown/foreign or CREATE is denied. * * @api */ #[RequiresPermission(GoalcanvasPermissions::CREATE, entityScoped: true)] public function createGoal($values) { $projectId = $this->goalRepository->getCanvasProjectId((int) ($values['canvasId'] ?? 0), self::CANVAS_TYPE); if ($projectId === null) { throw new AuthorizationException; } $this->authorize(GoalcanvasPermissions::CREATE, $projectId); $newId = $this->goalRepository->createGoal($values); if ($newId !== false && array_key_exists('milestoneId', $values)) { $this->syncGoalMilestoneEdges((int) $newId, $values['milestoneId'], (int) session('userdata.id')); } return $newId; } /** * Create a goal item (controller add-item path), authorized for CREATE against the target * board's real project. * * @param array $values Item values (must include `canvasId`) * @return false|string New item id, or false on insert failure * * @throws AuthorizationException When the target board is unknown/foreign or CREATE is denied. */ public function createGoalItem(array $values): false|string { $projectId = $this->goalRepository->getCanvasProjectId((int) ($values['canvasId'] ?? 0), self::CANVAS_TYPE); if ($projectId === null) { throw new AuthorizationException; } $this->authorize(GoalcanvasPermissions::CREATE, $projectId); $newId = $this->goalRepository->addCanvasItem($values); // Only reconcile edges when a real milestone id is supplied. A brand-new // item has no edges to clear, so an empty milestoneId — controllers post // '' for every box via a hidden input — would just cost a wasted lookup. // The update/patch paths still process empty values there, where clearing // an existing link is a meaningful edit. $milestoneIdValue = $values['milestoneId'] ?? null; if ($newId !== false && is_scalar($milestoneIdValue) && filter_var($milestoneIdValue, FILTER_VALIDATE_INT) > 0 ) { $this->syncGoalMilestoneEdges((int) $newId, $milestoneIdValue, (int) session('userdata.id')); } return $newId; } /** * Update a goal item, authorized for EDIT against the item's real project. The project is * resolved from the existing item's id, so the payload's canvasId cannot relocate it. * * @param array $values Item values (must include `itemId` or `id`) * * @throws AuthorizationException When the item is unknown/foreign or EDIT is denied. */ public function updateGoalItem(array $values): void { $itemId = (int) ($values['itemId'] ?? $values['id'] ?? 0); $projectId = $this->goalRepository->getCanvasItemProjectId($itemId, self::CANVAS_TYPE); if ($projectId === null) { throw new AuthorizationException; } $this->authorize(GoalcanvasPermissions::EDIT, $projectId); $this->goalRepository->editCanvasItem($values); if (array_key_exists('milestoneId', $values)) { $this->syncGoalMilestoneEdges($itemId, $values['milestoneId'], (int) session('userdata.id')); } } /** * Reconcile a goal's tracked_by milestone edges against the desired set. * Accepts a single milestone id (scalar — the transitional single-select * write) OR an array (multi-select). Set-based, so an unchanged save * doesn't churn edges and an empty value/array clears all links. * * @param mixed $milestoneIdValue int|string|array milestone id(s), or '' to clear */ private function syncGoalMilestoneEdges(int $goalId, mixed $milestoneIdValue, int $userId): void { if ($goalId <= 0) { return; } $desired = []; foreach (is_array($milestoneIdValue) ? $milestoneIdValue : [$milestoneIdValue] as $value) { // Strict int validation — (int) would coerce '42abc' to 42 and could // silently link the wrong milestone if a malformed value reaches this // path (e.g. via JSON-RPC). Non-scalar / non-int values are dropped. $milestoneId = is_scalar($value) ? filter_var($value, FILTER_VALIDATE_INT) : false; if ($milestoneId !== false && $milestoneId > 0) { $desired[] = $milestoneId; } } $desired = array_values(array_unique($desired)); $current = $this->goalRepository->getMilestoneIdsForGoal($goalId); foreach (array_diff($current, $desired) as $remove) { $this->goalRepository->removeGoalMilestoneLink($goalId, (int) $remove); } foreach (array_diff($desired, $current) as $add) { $this->goalRepository->addGoalMilestoneLink($goalId, (int) $add, $userId); } } /** * Patch allowlisted columns of a goal item, authorized for EDIT against the item's real * project. * * @param array $params Fields to patch (allowlisted in the repository) * * @throws AuthorizationException When the item is unknown/foreign or EDIT is denied. */ public function patchGoalItem(int $id, array $params): bool { $projectId = $this->goalRepository->getCanvasItemProjectId($id, self::CANVAS_TYPE); if ($projectId === null) { throw new AuthorizationException; } $this->authorize(GoalcanvasPermissions::EDIT, $projectId); $result = $this->goalRepository->patchCanvasItem($id, $params); // Only mirror the milestoneId change into the tracked_by edges when the // column patch actually persisted — otherwise the edges would drift from // the milestoneId column and break the dual-write invariant. if ($result && array_key_exists('milestoneId', $params)) { $this->syncGoalMilestoneEdges($id, $params['milestoneId'], (int) session('userdata.id')); } return $result; } /** * Delete a goal item, authorized for DELETE against the item's real project. * * @throws AuthorizationException When the item is unknown/foreign or DELETE is denied. */ public function deleteGoalItem(int $id): void { $projectId = $this->goalRepository->getCanvasItemProjectId($id, self::CANVAS_TYPE); if ($projectId === null) { throw new AuthorizationException; } $this->authorize(GoalcanvasPermissions::DELETE, $projectId); $this->goalRepository->delCanvasItem($id); $this->goalRepository->removeAllGoalMilestoneLinks($id); } /** * Poll all goals the user can access (optionally scoped to a project/board). The repository * query already filters to the user's accessible projects; the dispatch gate adds the * capability check. * * @return array * * @api */ #[RequiresPermission(GoalcanvasPermissions::VIEW, projectIdParam: 'projectId')] public function pollGoals(?int $projectId = null, ?int $board = null) { $goals = $this->goalRepository->getAllAccountGoals($projectId, $board); foreach ($goals as $key => $goal) { $goals[$key] = $this->prepareDatesForApiResponse($goal); } return $goals; } /** * @return array * * @api */ #[RequiresPermission(GoalcanvasPermissions::VIEW, projectIdParam: 'projectId')] public function pollForUpdatedGoals(?int $projectId = null, ?int $board = null): array|false { $goals = $this->goalRepository->getAllAccountGoals($projectId, $board); foreach ($goals as $key => $goal) { $goals[$key] = $this->prepareDatesForApiResponse($goal); $goals[$key]['id'] = $goal['id'].'-'.$goal['modified']; } return $goals; } private function prepareDatesForApiResponse($goal) { if (dtHelper()->isValidDateString($goal['created'])) { $goal['created'] = dtHelper()->parseDbDateTime($goal['created'])->toIso8601ZuluString(); } else { $goal['created'] = null; } if (dtHelper()->isValidDateString($goal['modified'])) { $goal['modified'] = dtHelper()->parseDbDateTime($goal['modified'])->toIso8601ZuluString(); } else { $goal['modified'] = null; } if (dtHelper()->isValidDateString($goal['startDate'])) { $goal['startDate'] = dtHelper()->parseDbDateTime($goal['startDate'])->toIso8601ZuluString(); } else { $goal['startDate'] = null; } if (dtHelper()->isValidDateString($goal['endDate'])) { $goal['endDate'] = dtHelper()->parseDbDateTime($goal['endDate'])->toIso8601ZuluString(); } else { $goal['endDate'] = null; } return $goal; } }