blueprintsRepo = $blueprintsRepo; $this->templateRegistry = $templateRegistry; $this->language = $language; $this->contentTemplates = $contentTemplates; } // --------------------------------------------------------------------------------------- // Secured by-id board/item CRUD chokepoint. // // Controllers call these instead of the repository so authorization happens against the // entity's REAL project. Reads soft-deny (return the same neutral value as "missing") so // they never become a cross-project existence oracle; writes fail closed with an // AuthorizationException. These are intentionally NOT @api — they are the canvas // controllers' write surface, not part of the JSON-RPC API. // --------------------------------------------------------------------------------------- /** * Fetch a single canvas item by id, authorized for VIEW against the item's real project. * * @param int $id Canvas item id * @param string $canvasType Board type the item must belong to (e.g. "swotcanvas") * @return array|false The item, or false when missing/foreign/unauthorized */ public function getCanvasItem(int $id, string $canvasType): array|false { $projectId = $this->blueprintsRepo->getCanvasItemProjectId($id, $canvasType); if ($projectId === null || ! $this->can(BlueprintsPermissions::VIEW, $projectId)) { return false; } return $this->blueprintsRepo->getSingleCanvasItem($id); } /** * Fetch a single canvas board by id, authorized for VIEW against the board's real project. * * @param int $canvasId Canvas board id * @param string $canvasType Board type the board must be of * @return array>|false Board rows, or false when missing/foreign/unauthorized */ public function getBoard(int $canvasId, string $canvasType): array|false { $projectId = $this->blueprintsRepo->getCanvasProjectId($canvasId, $canvasType); if ($projectId === null || ! $this->can(BlueprintsPermissions::VIEW, $projectId)) { return false; } return $this->blueprintsRepo->getSingleCanvas($canvasId, $canvasType); } /** * List the items of a canvas board, authorized for VIEW against the board's real project. * Returns an empty array (the neutral "no items" value) for a missing/foreign/unauthorized * board, so a foreign board id is indistinguishable from an empty one. * * @param int $canvasId Canvas board id * @param string $canvasType Board type the board must be of * @param string $commentModule Comment module key for the item comment count join * @return array> */ public function getBoardItems(int $canvasId, string $canvasType, string $commentModule): array { $projectId = $this->blueprintsRepo->getCanvasProjectId($canvasId, $canvasType); if ($projectId === null || ! $this->can(BlueprintsPermissions::VIEW, $projectId)) { return []; } return $this->blueprintsRepo->getCanvasItemsById($canvasId, $commentModule); } /** * Create a canvas item, authorized for CREATE against the target board's real project. * * @param array $values Item values (must include `canvasId`) * @param string $canvasType Board type the target board must be of * @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 createCanvasItem(array $values, string $canvasType): false|string { $projectId = $this->blueprintsRepo->getCanvasProjectId((int) ($values['canvasId'] ?? 0), $canvasType); if ($projectId === null) { throw new AuthorizationException; } $this->authorize(BlueprintsPermissions::CREATE, $projectId); return $this->blueprintsRepo->addCanvasItem($values); } /** * Update a canvas item, authorized for EDIT against the item's real project. The board id * is resolved from the existing item — `canvasId` in the payload is ignored for scope, so * an item cannot be relocated into another project. * * @param array $values Item values (must include `itemId` or `id`) * @param string $canvasType Board type the item must belong to * * @throws AuthorizationException When the item is unknown/foreign or EDIT is denied. */ public function updateCanvasItem(array $values, string $canvasType): void { $itemId = (int) ($values['itemId'] ?? $values['id'] ?? 0); $projectId = $this->blueprintsRepo->getCanvasItemProjectId($itemId, $canvasType); if ($projectId === null) { throw new AuthorizationException; } $this->authorize(BlueprintsPermissions::EDIT, $projectId); $this->blueprintsRepo->editCanvasItem($values); CanvasItemUpdated::dispatch( canvasItemId: $itemId, changedFields: $this->fieldNames($values), legacyHook: __FUNCTION__, ); } /** * Extract the canvas-item field names from a controller payload for the * CanvasItemUpdated event, dropping the transport/identifier keys (id, * itemId, canvasId, changeItem, routing params) that ride along in the * payload but are not columns — so `changedFields` reads as field names, * not request plumbing. * * @param array $payload * @return array */ private function fieldNames(array $payload): array { $transportKeys = ['id', 'itemId', 'canvasId', 'changeItem', 'action', 'module']; return array_values(array_diff(array_map('strval', array_keys($payload)), $transportKeys)); } /** * Patch allowlisted columns of a canvas item, authorized for EDIT against the item's real * project. Used by the inline board-update API. * * @param int $id Canvas item id * @param array $params Fields to patch (allowlisted in the repository) * @param string $canvasType Board type the item must belong to * @return bool False when no allowlisted columns were present (a client error, not a denial) * * @throws AuthorizationException When the item is unknown/foreign or EDIT is denied. */ public function patchCanvasItem(int $id, array $params, string $canvasType): bool { $projectId = $this->blueprintsRepo->getCanvasItemProjectId($id, $canvasType); if ($projectId === null) { throw new AuthorizationException; } $this->authorize(BlueprintsPermissions::EDIT, $projectId); $patched = $this->blueprintsRepo->patchCanvasItem($id, $params); if ($patched) { CanvasItemUpdated::dispatch( canvasItemId: $id, changedFields: $this->fieldNames($params), legacyHook: __FUNCTION__, ); } return $patched; } /** * Delete a canvas item, authorized for DELETE against the item's real project. * * @param int $id Canvas item id * @param string $canvasType Board type the item must belong to * * @throws AuthorizationException When the item is unknown/foreign or DELETE is denied. */ public function deleteCanvasItem(int $id, string $canvasType): void { $projectId = $this->blueprintsRepo->getCanvasItemProjectId($id, $canvasType); if ($projectId === null) { throw new AuthorizationException; } $this->authorize(BlueprintsPermissions::DELETE, $projectId); $this->blueprintsRepo->delCanvasItem($id); } /** * Create a canvas board, authorized for CREATE against the target project. * * @param array $values Board values (must include `projectId`) * @param string $canvasType Board type to create * @return false|string New board id, or false on insert failure * * @throws AuthorizationException When projectId is missing or CREATE is denied. */ public function createBoard(array $values, string $canvasType): false|string { $projectId = (int) ($values['projectId'] ?? 0); if ($projectId === 0) { throw new AuthorizationException; } $this->authorize(BlueprintsPermissions::CREATE, $projectId); $newId = $this->blueprintsRepo->addCanvas($values, $canvasType); if ($newId !== false) { $this->applyStartContent((int) $newId, $canvasType); } return $newId; } /** * If the blueprint for this canvas type declares a startContent * reference, look up the matching ContentTemplate and apply it to the * freshly-created board. Silent no-op when the blueprint has no * starter content or the referenced template can't be found — board * creation never fails because of a missing/broken starter. */ private function applyStartContent(int $canvasId, string $canvasType): void { // createBoard() is invoked with the DATABASE type (e.g. "swotcanvas", // "logicmodelcanvas"), but both registries key by the SLUG form // ("swot", "logicmodel"). Use getByDatabaseType() to bridge, then // pass the resolved slug to the ContentTemplates lookups so both // sides agree on the identifier. Prior to this the initial registry // read silently returned null and the whole feature was a no-op. $blueprint = $this->templateRegistry->getByDatabaseType($canvasType); if ($blueprint === null || $blueprint->startContent === null) { return; } $slug = $blueprint->slug; $contentTpl = $this->contentTemplates->get($slug, $blueprint->startContent); if ($contentTpl === null) { Log::debug(sprintf( 'Blueprints::createBoard: blueprint "%s" references startContent "%s" but the template was not found.', $slug, $blueprint->startContent )); return; } $applier = $this->contentTemplates->applierFor($slug); if ($applier === null) { return; } try { $applier->apply($canvasId, $contentTpl, [ 'userId' => (int) session('userdata.id'), 'mode' => 'add', ]); } catch (\Throwable $e) { // Don't let a bad starter break board creation. Log + move on. Log::warning(sprintf( 'Blueprints::createBoard: startContent "%s" failed to apply to canvas %d: %s', $blueprint->startContent, $canvasId, $e->getMessage() )); } } /** * Rename a canvas board, authorized for EDIT against the board's real project. * * @param int $canvasId Canvas board id * @param string $title New title * @param string $canvasType Board type the board must be of * * @throws AuthorizationException When the board is unknown/foreign or EDIT is denied. */ public function renameBoard(int $canvasId, string $title, string $canvasType): mixed { $projectId = $this->blueprintsRepo->getCanvasProjectId($canvasId, $canvasType); if ($projectId === null) { throw new AuthorizationException; } $this->authorize(BlueprintsPermissions::EDIT, $projectId); return $this->blueprintsRepo->updateCanvas(['id' => $canvasId, 'title' => $title]); } /** * Copy a canvas board into a target project. Requires VIEW on the SOURCE board's real * project (you must be able to read what you copy) AND CREATE in the target project. * * @param int $sourceCanvasId Source board id * @param int $targetProjectId Destination project id * @param int $authorId Author of the new board * @param string $title New board title * @param string $canvasType Board type the source must be of (and the copy will be) * @return int New board id * * @throws AuthorizationException When the source is unknown/foreign, or VIEW/CREATE is denied. */ public function copyBoard(int $sourceCanvasId, int $targetProjectId, int $authorId, string $title, string $canvasType): int { $sourceProjectId = $this->blueprintsRepo->getCanvasProjectId($sourceCanvasId, $canvasType); if ($sourceProjectId === null || $targetProjectId <= 0) { throw new AuthorizationException; } $this->authorize(BlueprintsPermissions::VIEW, $sourceProjectId); $this->authorize(BlueprintsPermissions::CREATE, $targetProjectId); return $this->blueprintsRepo->copyCanvas($targetProjectId, $sourceCanvasId, $authorId, $title, $canvasType); } /** * Merge a source board's items into a target board. Requires EDIT on the TARGET board's * real project and VIEW on the SOURCE board's real project — both resolved by id, so * neither can cross a project boundary. * * @param int $targetCanvasId Board receiving the items * @param int $sourceCanvasId Board whose items are copied * @param string $canvasType Board type both boards must be of * * @throws AuthorizationException When either board is unknown/foreign, or EDIT/VIEW is denied. */ public function mergeBoard(int $targetCanvasId, int $sourceCanvasId, string $canvasType): bool { $targetProjectId = $this->blueprintsRepo->getCanvasProjectId($targetCanvasId, $canvasType); $sourceProjectId = $this->blueprintsRepo->getCanvasProjectId($sourceCanvasId, $canvasType); if ($targetProjectId === null || $sourceProjectId === null) { throw new AuthorizationException; } $this->authorize(BlueprintsPermissions::EDIT, $targetProjectId); $this->authorize(BlueprintsPermissions::VIEW, $sourceProjectId); return $this->blueprintsRepo->mergeCanvas($targetCanvasId, $sourceCanvasId); } /** * Delete a canvas board (and its items), authorized for DELETE against the board's real * project. * * @param int $canvasId Canvas board id * @param string $canvasType Board type the board must be of * * @throws AuthorizationException When the board is unknown/foreign or DELETE is denied. */ public function deleteBoard(int $canvasId, string $canvasType): void { $projectId = $this->blueprintsRepo->getCanvasProjectId($canvasId, $canvasType); if ($projectId === null) { throw new AuthorizationException; } $this->authorize(BlueprintsPermissions::DELETE, $projectId); $this->blueprintsRepo->deleteCanvas($canvasId); } /** * Validate that a resolved import file path is safe to read. * * Rejects files outside a fixed allow-list of local directories and * requires a known extension. The caller must resolve the path via * {@see realpath()} first — realpath canonicalizes the path (resolves * symlinks, relative segments, and `..` traversal) so the allow-list * check operates on the true absolute path rather than the * user-supplied string. * * The allow-list covers two directories: * - the PHP upload temp directory (UI file-upload flow), and * - the shipped fixture directory under the Blueprints domain. * * base_path('userfiles') is intentionally EXCLUDED: the global userfiles * storage is managed by the Files domain with per-file authorization. * Allowing import() to read arbitrary .xml files from userfiles would * bypass that authorization — a caller with CREATE on any project could * ingest files they should not have access to. * * .xml files in sys_get_temp_dir() are accepted by design: this is how PHP * delivers uploaded files to the application (upload_tmp_dir / sys_temp_dir). * On Unix systems the temp directory is typically world-writable with the * sticky bit; the allow-list check is the gate, and we accept the residual * risk that another local user could place a malicious .xml there — that * attacker already has local code execution as the web server user, so * crafting a temp file does not represent an additional escalation. * * @param string $resolvedPath Already-resolved absolute path (from realpath) * @return bool True when the path is within an allowed directory * and has an allowed extension */ private function isImportPathAllowed(string $resolvedPath): bool { $allowedDirs = [ sys_get_temp_dir(), APP_ROOT.'/app/Domain/Blueprints/imports', ]; // Validate file extension — only XML is permitted because import() // parses via DOMDocument::loadXML(). Shipped fixture files under the // imports/ directory are .xml as well. $ext = strtolower(pathinfo($resolvedPath, PATHINFO_EXTENSION)); if ($ext !== 'xml') { Log::warning('Blueprints import: disallowed file extension', [ 'resolvedPath' => $resolvedPath, 'extension' => $ext, ]); return false; } // Anchor each allowed directory with a trailing separator so // str_starts_with doesn't match sibling-prefix paths (e.g. // /tmp-evil/x must NOT match against allowed /tmp). foreach ($allowedDirs as $allowedDir) { $resolvedAllowed = realpath($allowedDir); if ($resolvedAllowed === false) { continue; } if (str_starts_with($resolvedPath, $resolvedAllowed.DIRECTORY_SEPARATOR)) { return true; } } Log::warning('Blueprints import: path traversal or SSRF attempt blocked', [ 'resolvedPath' => $resolvedPath, ]); return false; } /** * Import a canvas board from an XML file. * * Parses the XML, validates its structure, then creates a new canvas board * with all items from the file. * * @param string $filename Path to the XML file * @param string $canvasSlug Canvas type slug (e.g., "swot", "lean") * @param int $projectId Project identifier * @param int $authorId Author user identifier * @return bool|int False on failure, or the new canvas board ID on success * * @throws BindingResolutionException * @throws AuthorizationException When the user cannot create canvases in $projectId. * * @api */ #[RequiresPermission(BlueprintsPermissions::CREATE, entityScoped: true)] public function import(string $filename, string $canvasSlug, int $projectId, int $authorId): bool|int { // Authorize CREATE against the TARGET project (the destination of the import), not the // session project — import is reachable via RPC with an arbitrary projectId. $this->authorize(BlueprintsPermissions::CREATE, $projectId); $template = $this->templateRegistry->get($canvasSlug); if ($template === null) { Log::error("Blueprints import failed: unknown canvas slug '{$canvasSlug}'"); return false; } $dom = new DOMDocument('1.0', 'UTF-8'); // Validate the file path and extension to prevent SSRF and Local File // Inclusion. Reject URL wrappers (http://, ftp://, etc.), restrict // reads to allowed local directories, and require a known import // extension. $resolvedPath = realpath($filename); if ($resolvedPath === false) { Log::warning('Blueprints import: file not found or path does not exist', [ 'filename' => $filename, ]); return false; } if (! $this->isImportPathAllowed($resolvedPath)) { return false; } // Guard against non-regular files (FIFO, device, socket) in // world-writable /tmp — a named pipe named *.xml would hang the // request if read without this check. if (! is_file($resolvedPath) || ! is_readable($resolvedPath)) { Log::warning('Blueprints import: path is not a readable regular file', [ 'resolvedPath' => $resolvedPath, ]); return false; } $canvasData = file_get_contents($resolvedPath); if ($canvasData === false) { return false; } // Defend against XXE-based SSRF: LIBXML_NONET disables network access // during parsing. PHP 8.0+ disables external entity loading by default; // this flag provides defense-in-depth for older or misconfigured builds. $oldInternalErrors = libxml_use_internal_errors(true); $oldErrorReporting = error_reporting(error_reporting() & ~E_WARNING); $status = $dom->loadXML($canvasData, LIBXML_NONET); error_reporting($oldErrorReporting); libxml_use_internal_errors($oldInternalErrors); if ($status === false) { return false; } $canvasAry = ['projectId' => $projectId, 'author' => $authorId]; $recordsAry = []; $canvasNodeList = $dom->getElementsByTagName('canvas'); if ($canvasNodeList->count() !== 1) { return false; } $importedCanvasName = $canvasNodeList->item(0)->getAttribute('key'); $titleNodeList = $canvasNodeList->item(0)->getElementsByTagName('title'); if ($titleNodeList->count() !== 1) { return false; } $canvasAry['title'] = $titleNodeList->item(0)->nodeValue; $dataNodeList = $canvasNodeList->item(0)->getElementsByTagName('content'); if ($dataNodeList->count() !== 1) { return false; } $elementNodeList = $dataNodeList->item(0)->getElementsByTagName('element'); // Resolved here rather than at the top of the method: it is only needed to map // item authors below, so a rejected path or malformed document never pays for // building a database-backed repository. $users = app()->make(UserRepository::class); foreach ($elementNodeList as $elementNode) { if (! $elementNode->hasAttribute('key')) { return false; } $elementKey = $elementNode->getAttribute('key'); $itemNodeList = $elementNode->getElementsByTagName('item'); foreach ($itemNodeList as $itemName) { $authorNodeList = $itemName->getElementsByTagName('author'); if ($authorNodeList->count() !== 1) { return false; } if (! $authorNodeList->item(0)->hasAttribute('firstname')) { return false; } $authorFirstname = $authorNodeList->item(0)->getAttribute('firstname'); if (! $authorNodeList->item(0)->hasAttribute('lastname')) { return false; } $authorLastname = $authorNodeList->item(0)->getAttribute('lastname'); $author = $users->getUserIdByName($authorFirstname, $authorLastname); if ($author === false) { $author = $authorId; } $descriptionNodeList = $itemName->getElementsByTagName('description'); if ($descriptionNodeList->count() !== 1) { return false; } $description = $descriptionNodeList->item(0)->nodeValue; $statusNodeList = $itemName->getElementsByTagName('status'); if ($statusNodeList->count() !== 1) { return false; } if (! $statusNodeList->item(0)->hasAttribute('key')) { return false; } $statusKey = $statusNodeList->item(0)->getAttribute('key'); $relatesNodeList = $itemName->getElementsByTagName('relates'); if ($relatesNodeList->count() !== 1) { return false; } if (! $relatesNodeList->item(0)->hasAttribute('key')) { return false; } $relates = $relatesNodeList->item(0)->getAttribute('key'); $assumptionsNodeList = $itemName->getElementsByTagName('assumptions'); if ($assumptionsNodeList->count() !== 1) { return false; } $assumptions = empty($assumptionsNodeList->item(0)->nodeValue) ? '' : $dom->saveHTML($assumptionsNodeList->item(0)->firstChild); $importDataNodeList = $itemName->getElementsByTagName('data'); if ($importDataNodeList->count() !== 1) { return false; } $data = empty($importDataNodeList->item(0)->nodeValue) ? '' : $dom->saveHTML($importDataNodeList->item(0)->firstChild); $conclusionNodeList = $itemName->getElementsByTagName('conclusion'); if ($conclusionNodeList->count() !== 1) { return false; } $conclusion = empty($conclusionNodeList->item(0)->nodeValue) ? '' : $dom->saveHTML($conclusionNodeList->item(0)->firstChild); $recordsAry[] = [ 'description' => $description, 'assumptions' => $assumptions, 'data' => $data, 'conclusion' => $conclusion, 'box' => $elementKey, 'author' => $author, 'status' => $statusKey, 'relates' => $relates, 'milestoneId' => '', ]; } } $expectedCanvasKey = $template->getDatabaseType(); if ($expectedCanvasKey !== $importedCanvasName) { return false; } $canvasType = $template->getDatabaseType(); $canvasAry['title'] .= ' [imported]'; if ($this->blueprintsRepo->existCanvas($projectId, $canvasAry['title'], $canvasType)) { return false; } $canvasId = $this->blueprintsRepo->addCanvas($canvasAry, $canvasType); if ($canvasId === false) { return false; } foreach ($recordsAry as $record) { $record['canvasId'] = $canvasId; $this->blueprintsRepo->addCanvasItem($record); } return (int) $canvasId; } /** * Get progress percentages for canvas boards in a project. * * Counts items per box type for each canvas and calculates what fraction * of box types have at least one item. * * @param string $projectId Project identifier (empty string for all) * @param array $boards Array of database canvas types to check * @return array Map of canvas type to max progress (0.0 to 1.0) * * @throws BindingResolutionException * * @api */ #[RequiresPermission(BlueprintsPermissions::VIEW, projectIdParam: 'projectId')] public function getBoardProgress(string $projectId = '', array $boards = []): array { $values = $this->blueprintsRepo->getCanvasProgressCount((int) $projectId, $boards); $results = []; foreach ($values as $row) { $canvasType = $row['canvasType']; if (! isset($results[$canvasType])) { $results[$canvasType] = []; } if (! isset($results[$canvasType][$row['canvasId']])) { $template = $this->templateRegistry->getByDatabaseType($canvasType); $results[$canvasType][$row['canvasId']] = []; if ($template !== null) { foreach ($template->boxes as $type => $box) { $results[$canvasType][$row['canvasId']][$type] = 0; } } } if ($row['box'] != '' && $row['boxItems'] > 0) { $results[$canvasType][$row['canvasId']][$row['box']]++; } } $progressResults = []; foreach ($results as $key => &$canvas) { $template = $this->templateRegistry->getByDatabaseType($key); $numOfBoxes = $template !== null ? count($template->boxes) : 1; if (! isset($progressResults[$key])) { $progressResults[$key] = ''; } $maxProgress = 0; foreach ($canvas as $canvasId => $singleCanvas) { $numOfBoxesFilled = 0; foreach ($singleCanvas as $box) { if ($box > 0) { $numOfBoxesFilled++; } } $progress = $numOfBoxesFilled / $numOfBoxes; if ($progress > $maxProgress) { $maxProgress = $progress; } } $progressResults[$key] = $maxProgress; } return $progressResults; } /** * Get canvas boards ordered by last updated item. * * @param int|null $projectId Project identifier (null for all) * @param array $boards Array of database canvas types to filter by * @return array> List of canvas boards with modification dates * * @throws BindingResolutionException * * @api */ #[RequiresPermission(BlueprintsPermissions::VIEW, projectIdParam: 'projectId')] public function getLastUpdatedCanvas(?int $projectId = null, array $boards = []): array { return $this->blueprintsRepo->getLastUpdatedCanvas((int) $projectId, $boards); } /** * Translate the box labels from a CanvasTemplate. * * Returns the boxes array with title values run through the language service. * * @param CanvasTemplate $template Canvas template * @return array> Translated box definitions */ public function getTranslatedBoxes(CanvasTemplate $template): array { $boxes = $template->boxes; foreach ($boxes as $key => $data) { if (isset($data['title'])) { $boxes[$key]['title'] = $this->language->__($data['title']); } } return $boxes; } /** * Translate the status labels from a CanvasTemplate. * * @param CanvasTemplate $template Canvas template * @return array> Translated status labels */ public function getTranslatedStatusLabels(CanvasTemplate $template): array { $statusLabels = $template->statusLabels; foreach ($statusLabels as $key => $data) { if (isset($data['title'])) { $statusLabels[$key]['title'] = $this->language->__($data['title']); } } return $statusLabels; } /** * Translate the relates labels from a CanvasTemplate. * * @param CanvasTemplate $template Canvas template * @return array> Translated relates labels */ public function getTranslatedRelatesLabels(CanvasTemplate $template): array { $relatesLabels = $template->relatesLabels; foreach ($relatesLabels as $key => $data) { if (isset($data['title'])) { $relatesLabels[$key]['title'] = $this->language->__($data['title']); } } return $relatesLabels; } /** * Translate the data labels from a CanvasTemplate. * * @param CanvasTemplate $template Canvas template * @return array> Translated data labels */ public function getTranslatedDataLabels(CanvasTemplate $template): array { $dataLabels = $template->dataLabels; foreach ($dataLabels as $key => $data) { if (isset($data['title'])) { $dataLabels[$key]['title'] = $this->language->__($data['title']); } } return $dataLabels; } /** * Translate the disclaimer string from a CanvasTemplate. * * @param CanvasTemplate $template Canvas template * @return string Translated disclaimer, or empty string if none */ public function getTranslatedDisclaimer(CanvasTemplate $template): string { if (empty($template->disclaimer)) { return ''; } return $this->language->__($template->disclaimer); } /** * Returns the metadata map for every selectable blueprint board (canvas) type. * * Each entry holds the routing module, the translatable name/description labels, * an icon class and the (empty) placeholders used when no board of that type exists yet. * * @return array> Board type keyed metadata map. */ public function getBoardMetadata(): array { return [ 'logicmodelcanvas' => ['module' => 'logicmodelcanvas', 'name' => 'label.logicmodelcanvas', 'description' => 'description.logicmodelcanvas', 'icon' => 'fa-solid fa-diagram-project', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''], 'valuecanvas' => ['module' => 'blueprints/value', 'name' => 'label.valuecanvas', 'description' => 'description.valuecanvas', 'icon' => 'fa-solid fa-ranking-star', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''], 'swotcanvas' => ['module' => 'blueprints/swot', 'name' => 'label.swotcanvas', 'description' => 'description.swotcanvas', 'icon' => 'fa-solid fa-dumbbell', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''], 'obmcanvas' => ['module' => 'blueprints/obm', 'name' => 'label.obmcanvas', 'description' => 'description.obmcanvas', 'icon' => 'fa-solid fa-object-group', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''], 'leancanvas' => ['module' => 'blueprints/lean', 'name' => 'label.leancanvas', 'description' => 'description.leancanvas', 'icon' => 'fa-solid fa-person-circle-question', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''], 'minempathycanvas' => ['module' => 'blueprints/minempathy', 'name' => 'label.minempathycanvas', 'description' => 'description.minempathycanvas', 'icon' => 'fa-solid fa-heart-circle-check', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''], 'sbcanvas' => ['module' => 'blueprints/sb', 'name' => 'label.sbcanvas', 'description' => 'description.sbcanvas', 'icon' => 'fa-solid fa-briefcase', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''], 'riskscanvas' => ['module' => 'blueprints/risks', 'name' => 'label.riskscanvas', 'description' => 'description.riskscanvas', 'icon' => 'fa-solid fa-triangle-exclamation', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''], 'eacanvas' => ['module' => 'blueprints/ea', 'name' => 'label.eacanvas', 'description' => 'description.eacanvas', 'icon' => 'fa-solid fa-seedling', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''], 'lbmcanvas' => ['visible' => '0', 'module' => 'blueprints/lbm', 'name' => 'label.lbmcanvas', 'description' => 'description.lbmcanvas', 'icon' => 'fa-solid fa-building', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''], 'dbmcanvas' => ['visible' => '0', 'module' => 'blueprints/dbm', 'name' => 'label.dbmcanvas', 'description' => 'description.dbmcanvas', 'icon' => 'fa-solid fa-city', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''], 'sqcanvas' => ['visible' => '0', 'module' => 'blueprints/sq', 'name' => 'label.sqcanvas', 'description' => 'description.sqcanvas', 'icon' => 'fa fa-chess', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''], 'insightscanvas' => ['module' => 'blueprints/insights', 'name' => 'label.insightscanvas', 'description' => 'description.insightscanvas', 'icon' => 'fa-solid fa-arrows-down-to-people', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''], 'cpcanvas' => ['visible' => '0', 'module' => 'blueprints/cp', 'name' => 'label.cpcanvas', 'description' => 'description.cpcanvas', 'icon' => 'fa-solid fa-list-check', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''], 'smcanvas' => ['visible' => '0', 'module' => 'blueprints/sm', 'name' => 'label.smcanvas', 'description' => 'description.smcanvas', 'icon' => 'fa-solid fa-comments-dollar', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''], 'emcanvas' => ['visible' => '0', 'module' => 'blueprints/em', 'name' => 'label.emcanvas', 'description' => 'description.emcanvas', 'icon' => 'fa-solid fa-hand-holding-heart', 'numberOfBoards' => '', 'lastTitle' => '', 'lastCanvasId' => '', 'lastUpdate' => ''], ]; } /** * Returns the ordered list of blueprint board (canvas) types used to query progress and recent activity. * * @return array List of board type keys. */ public function getBoardTypes(): array { return [ 'emcanvas', 'smcanvas', 'cpcanvas', 'insightscanvas', 'sqcanvas', 'dbmcanvas', 'lbmcanvas', 'eacanvas', 'riskscanvas', 'sbcanvas', 'swotcanvas', 'obmcanvas', 'valuecanvas', 'leancanvas', 'minempathycanvas', ]; // Note: logicmodelcanvas is intentionally absent. It is its own domain (no // Blueprints YAML template), so it can't go through the template-based // progress/recent computation here — it would hit undefined box keys // (e.g. "lm_inputs"). It still appears in the hub via getBoardMetadata(). } /** * Merges the recently updated canvas boards into the board metadata map. * * For the first occurrence of a board type the metadata entry is seeded with the * latest board's count, title, modified date and id, and that type is removed from the * remaining "other" board list. Subsequent occurrences only increment the count. * * @param array> $recentlyUpdatedCanvas Canvas rows ordered by last updated item. * @param array> $boardMetadata Board type keyed metadata map (passed by reference so the consumed types are removed). * @return array> The recently used board metadata keyed by board type. */ public function buildRecentProgressCanvas(array $recentlyUpdatedCanvas, array &$boardMetadata): array { $recentProgressCanvas = []; foreach ($recentlyUpdatedCanvas as $canvas) { if (! isset($recentProgressCanvas[$canvas['type']])) { $recentProgressCanvas[$canvas['type']] = $boardMetadata[$canvas['type']]; $recentProgressCanvas[$canvas['type']]['count'] = 1; $recentProgressCanvas[$canvas['type']]['lastTitle'] = $canvas['title']; $recentProgressCanvas[$canvas['type']]['lastUpdate'] = $canvas['modified']; $recentProgressCanvas[$canvas['type']]['lastCanvasId'] = $canvas['id']; unset($boardMetadata[$canvas['type']]); } else { $recentProgressCanvas[$canvas['type']]['count']++; } } return $recentProgressCanvas; } /** * Builds the blueprints boards overview for a project. * * Loads the recently updated boards and board progress for the project, merges the recent * activity into the board metadata and returns a ready-to-render structure for the boards page. * * @param int $projectId Active project identifier. * @return array{recentProgressCanvas: array>, otherBoards: array>, recentlyUpdatedCanvas: array>, canvasProgress: array} Render-ready overview data. * * @throws BindingResolutionException */ public function getBoardsOverview(int $projectId): array { $boardMetadata = $this->getBoardMetadata(); $boards = $this->getBoardTypes(); $recentlyUpdatedCanvas = $this->getLastUpdatedCanvas($projectId, $boards); $recentProgressCanvas = $this->buildRecentProgressCanvas($recentlyUpdatedCanvas, $boardMetadata); $canvasProgress = $this->getBoardProgress((string) $projectId, $boards); return [ 'recentProgressCanvas' => $recentProgressCanvas, 'otherBoards' => $boardMetadata, 'recentlyUpdatedCanvas' => $recentlyUpdatedCanvas, 'canvasProgress' => $canvasProgress, ]; } }