OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Blueprints\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Http\IncomingRequest;
use Leantime\Core\UI\Template;
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
use Symfony\Component\HttpFoundation\Response;
/**
* ApiCanvas controller - handles PATCH requests for inline canvas item updates.
*
* Provides the API endpoint used by the blueprintsController.js for inline
* status, relates, and user dropdown updates on the canvas board.
*/
class ApiCanvas
{
private string $canvasSlug;
private ?CanvasTemplate $template;
/**
* __construct - resolve dependencies and determine the canvas slug from request.
*
* @param IncomingRequest $request Incoming request
* @param Template $tpl Template handler
* @param BlueprintsService $blueprintsService Blueprints service (project-authorized item CRUD)
* @param TemplateRegistry $templateRegistry Template registry
*/
public function __construct(
private IncomingRequest $request,
private Template $tpl,
private BlueprintsService $blueprintsService,
TemplateRegistry $templateRegistry,
) {
$this->canvasSlug = strip_tags((string) ($request->route('canvasSlug') ?? ''));
$this->template = $templateRegistry->get($this->canvasSlug);
}
/**
* patch - handle PATCH requests for inline canvas item updates.
*
* Supports updating status, relates, and author fields on individual
* canvas items via AJAX calls from the board view dropdowns.
*/
#[RequiresPermission(BlueprintsPermissions::EDIT, entityScoped: true)]
public function patch(): Response
{
$data = $this->request->getRequestParams();
if ($this->template === null) {
return $this->tpl->displayJson(['status' => 'Unknown canvas type'], 404);
}
if (! isset($data['id'])) {
return $this->tpl->displayJson(['status' => 'failure'], 400);
}
// The service resolves the item's REAL project and authorizes EDIT against it before
// patching — closing the by-id cross-project mutation IDOR (a missing/foreign item or
// an insufficient role throws AuthorizationException -> 403). A false return means no
// allowlisted columns were present, which is a client error, not a denial.
if (! $this->blueprintsService->patchCanvasItem((int) $data['id'], $data, $this->template->getDatabaseType())) {
return $this->tpl->displayJson(['status' => 'no valid fields to update'], 400);
}
return $this->tpl->displayJson(['status' => 'ok']);
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Blueprints\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\Http\IncomingRequest;
use Leantime\Core\Language;
use Leantime\Core\Mailer as MailerCore;
use Leantime\Core\UI\Template;
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
use Leantime\Domain\Blueprints\Repositories\Blueprints as BlueprintsRepository;
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Queue\Repositories\Queue as QueueRepository;
use Symfony\Component\HttpFoundation\Response;
/**
* BoardDialog controller - handles the create/edit board dialog for blueprints.
*
* Replaces the old per-variant Canvas\Controllers\BoardDialog subclasses.
* The canvas type slug comes from a GET parameter instead of a class constant.
*/
class BoardDialog
{
private string $canvasSlug;
private ?CanvasTemplate $template;
/**
* __construct - resolve dependencies and determine the canvas slug from request.
*
* @param IncomingRequest $request Incoming request
* @param Template $tpl Template engine
* @param Language $language Language service
* @param ProjectService $projectService Project service
* @param BlueprintsService $blueprintsService Blueprints service (project-authorized board CRUD)
* @param BlueprintsRepository $blueprintsRepo Blueprints repository (currentProject-scoped existence check)
* @param TemplateRegistry $templateRegistry Template registry
*/
public function __construct(
private IncomingRequest $request,
private Template $tpl,
private Language $language,
private ProjectService $projectService,
private BlueprintsService $blueprintsService,
private BlueprintsRepository $blueprintsRepo,
TemplateRegistry $templateRegistry,
) {
$this->canvasSlug = strip_tags((string) ($request->route('canvasSlug') ?? ''));
$this->template = $templateRegistry->get($this->canvasSlug);
}
/**
* get - display the create/edit board dialog.
*
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
* @param string|null $id Current board id
*/
#[RequiresPermission(BlueprintsPermissions::VIEW, entityScoped: true)]
public function get(?string $canvasSlug = null, ?string $id = null): Response
{
if ($this->template === null) {
return $this->tpl->displayPartial('errors.error404');
}
$currentCanvasId = '';
$canvasTitle = '';
if ($id !== null) {
// getBoard authorizes VIEW against the board's real project; false = missing /
// foreign / unauthorized, in which case we neither expose the title nor switch
// the active board (no session poisoning with a foreign id).
$singleCanvas = $this->blueprintsService->getBoard((int) $id, $this->template->getDatabaseType());
if ($singleCanvas !== false) {
$currentCanvasId = (int) $id;
$canvasTitle = $singleCanvas[0]['title'] ?? '';
session([$this->template->getSessionKey() => $currentCanvasId]);
}
}
return $this->renderDialog($currentCanvasId, $canvasTitle);
}
/**
* post - handle create/edit board submissions.
*
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
* @param string|null $id Current board id
*/
#[RequiresPermission(BlueprintsPermissions::EDIT, entityScoped: true)]
public function post(?string $canvasSlug = null, ?string $id = null): Response
{
if ($this->template === null) {
return $this->tpl->displayPartial('errors.error404');
}
$canvasType = $this->template->getDatabaseType();
$sessionKey = $this->template->getSessionKey();
$basePath = '/blueprints/'.$this->canvasSlug;
$currentCanvasId = ($id !== null && $id !== '') ? (int) $id : '';
$canvasTitle = '';
if (is_int($currentCanvasId) && $currentCanvasId > 0) {
$singleCanvas = $this->blueprintsService->getBoard($currentCanvasId, $canvasType);
if ($singleCanvas !== false) {
$canvasTitle = $singleCanvas[0]['title'] ?? '';
session([$sessionKey => $currentCanvasId]);
}
}
// Add Canvas
if ($this->request->has('newCanvas')) {
if ($this->request->has('canvastitle') && ! empty($this->request->input('canvastitle'))) {
if (! $this->blueprintsRepo->existCanvas(session('currentProject'), $this->request->input('canvastitle'), $canvasType)) {
$values = [
'title' => $this->request->input('canvastitle'),
'author' => session('userdata.id'),
'projectId' => session('currentProject'),
];
// createBoard authorizes CREATE against the target (current) project.
$currentCanvasId = $this->blueprintsService->createBoard($values, $canvasType);
$mailer = app()->make(MailerCore::class);
$users = $this->projectService->getUsersToNotify(session('currentProject'));
$mailer->setSubject($this->language->__('notification.board_created'));
$message = sprintf(
$this->language->__('email_notifications.canvas_created_message'),
session('userdata.name'),
"<a href='".CURRENT_URL."'>".strip_tags($values['title']).'</a>'
);
$mailer->setHtml($message);
$queue = app()->make(QueueRepository::class);
$queue->queueMessageToUsers(
$users,
$message,
$this->language->__('notification.board_created'),
session('currentProject')
);
$this->tpl->setNotification(
$this->language->__('notification.board_created'),
'success',
$this->canvasSlug.'board_created'
);
session([$sessionKey => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.$basePath.'/boardDialog/'.$currentCanvasId);
}
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
} else {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
}
}
// Edit Canvas
if ($this->request->has('editCanvas') && is_int($currentCanvasId) && $currentCanvasId > 0) {
if ($this->request->has('canvastitle') && ! empty($this->request->input('canvastitle'))) {
if (! $this->blueprintsRepo->existCanvas(session('currentProject'), $this->request->input('canvastitle'), $canvasType)) {
// renameBoard authorizes EDIT against the board's real project.
$this->blueprintsService->renameBoard($currentCanvasId, $this->request->input('canvastitle'), $canvasType);
$this->tpl->setNotification($this->language->__('notification.board_edited'), 'success');
return Frontcontroller::redirect(BASE_URL.$basePath.'/boardDialog/'.$currentCanvasId);
}
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
} else {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
}
}
return $this->renderDialog($currentCanvasId, $canvasTitle);
}
/**
* renderDialog - assign shared template variables and render the board dialog.
*
* @param int|string $currentCanvasId Current board id (empty string when creating)
* @param string $canvasTitle Current board title
*/
private function renderDialog(int|string $currentCanvasId, string $canvasTitle): Response
{
$this->tpl->assign('currentCanvas', $currentCanvasId);
$this->tpl->assign('canvasName', $this->canvasSlug);
$this->tpl->assign('canvasSlug', $this->canvasSlug);
$this->tpl->assign('canvasTitle', $canvasTitle);
$this->tpl->assign('users', $this->projectService->getUsersAssignedToProject(session('currentProject')));
return $this->tpl->displayPartial('blueprints.boardDialog');
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Blueprints\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\Http\IncomingRequest;
use Leantime\Core\Language;
use Leantime\Core\UI\Template;
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
use Leantime\Domain\Blueprints\Repositories\Blueprints as BlueprintsRepository;
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
use Symfony\Component\HttpFoundation\Response;
/**
* DelCanvas controller - handles canvas board deletion.
*
* Native Laravel controller: route-bound actions, the {canvasSlug}/{id} path segments
* arrive via the route (canvasSlug resolved in the constructor, id as a typed action arg),
* and request input is read from the injected IncomingRequest instead of the legacy
* merged-$params argument and superglobals.
*/
class DelCanvas
{
private string $canvasSlug;
private ?CanvasTemplate $template;
public function __construct(
private IncomingRequest $request,
private Template $tpl,
private Language $language,
private BlueprintsService $blueprintsService,
private BlueprintsRepository $blueprintsRepo,
TemplateRegistry $templateRegistry,
) {
$this->canvasSlug = strip_tags((string) ($request->route('canvasSlug') ?? ''));
$this->template = $templateRegistry->get($this->canvasSlug);
}
/**
* get - display the delete confirmation dialog.
*
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
* @param string|null $id Board id from the route
*/
#[RequiresPermission(BlueprintsPermissions::DELETE)]
public function get(?string $canvasSlug = null, ?string $id = null): Response
{
if ($this->template === null) {
return $this->tpl->displayPartial('errors.error404');
}
// The route id is optional in the pattern but mandatory in practice: every caller
// links with a concrete id. Validate strictly rather than sanitising — FILTER_SANITIZE_NUMBER_INT
// lets "1-2" through, which a later (int) cast would silently read as 1 and act on the
// wrong record. Fail closed instead.
$canvasId = filter_var($id, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($canvasId === false) {
return $this->tpl->displayPartial('errors.error404');
}
$this->tpl->assign('canvasSlug', $this->canvasSlug);
$this->tpl->assign('id', $canvasId);
return $this->tpl->displayPartial('blueprints.delCanvas');
}
/**
* post - process the board deletion.
*
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
* @param string|null $id Board id from the route
*/
#[RequiresPermission(BlueprintsPermissions::DELETE, entityScoped: true)]
public function post(?string $canvasSlug = null, ?string $id = null): Response
{
if ($this->template === null) {
return $this->tpl->displayPartial('errors.error404');
}
// The route id is optional in the pattern but mandatory in practice: every caller
// links with a concrete id. Validate strictly rather than sanitising — FILTER_SANITIZE_NUMBER_INT
// lets "1-2" through, which a later (int) cast would silently read as 1 and act on the
// wrong record. Fail closed instead.
$canvasId = filter_var($id, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($canvasId === false) {
return $this->tpl->displayPartial('errors.error404');
}
$canvasType = $this->template->getDatabaseType();
$sessionKey = $this->template->getSessionKey();
if ($this->request->has('del')) {
// The service resolves the board's REAL project and authorizes DELETE against it
// (throwing 403 for a missing/foreign board) — closing the by-id board-delete IDOR
// that the previous role-only Auth::authOrRedirect left open.
$this->blueprintsService->deleteBoard($canvasId, $canvasType);
$allCanvas = $this->blueprintsRepo->getAllCanvas(session('currentProject'), $canvasType);
session([$sessionKey => $allCanvas[0]['id'] ?? -1]);
$this->tpl->setNotification(
$this->language->__('notification.board_deleted'),
'success',
strtoupper($this->canvasSlug).'canvas_deleted'
);
if (! $allCanvas) {
return Frontcontroller::redirect(BASE_URL.'/blueprints/showBoards');
}
return Frontcontroller::redirect(BASE_URL.'/blueprints/'.$this->canvasSlug.'/showCanvas');
}
$this->tpl->assign('canvasSlug', $this->canvasSlug);
$this->tpl->assign('id', $canvasId);
return $this->tpl->displayPartial('blueprints.delCanvas');
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Blueprints\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\Http\IncomingRequest;
use Leantime\Core\Language;
use Leantime\Core\UI\Template;
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
use Symfony\Component\HttpFoundation\Response;
/**
* DelCanvasItem controller - handles canvas item deletion.
*
* Native Laravel controller: route-bound actions, the {canvasSlug}/{id} path segments
* arrive via the route (canvasSlug resolved in the constructor, id as a typed action arg),
* and request input is read from the injected IncomingRequest instead of the legacy
* merged-$params argument and superglobals.
*/
class DelCanvasItem
{
private string $canvasSlug;
private ?CanvasTemplate $template;
public function __construct(
private IncomingRequest $request,
private Template $tpl,
private Language $language,
private BlueprintsService $blueprintsService,
TemplateRegistry $templateRegistry,
) {
$this->canvasSlug = strip_tags((string) ($request->route('canvasSlug') ?? ''));
$this->template = $templateRegistry->get($this->canvasSlug);
}
/**
* get - display the delete confirmation dialog for a canvas item.
*
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
* @param string|null $id Canvas item id from the route
*/
#[RequiresPermission(BlueprintsPermissions::DELETE)]
public function get(?string $canvasSlug = null, ?string $id = null): Response
{
if ($this->template === null) {
return $this->tpl->displayPartial('errors.error404');
}
// The route id is optional in the pattern but mandatory in practice: every caller
// links with a concrete id. Validate strictly rather than sanitising — FILTER_SANITIZE_NUMBER_INT
// lets "1-2" through, which a later (int) cast would silently read as 1 and act on the
// wrong record. Fail closed instead.
$canvasId = filter_var($id, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($canvasId === false) {
return $this->tpl->displayPartial('errors.error404');
}
$this->tpl->assign('canvasSlug', $this->canvasSlug);
$this->tpl->assign('id', $canvasId);
return $this->tpl->displayPartial('blueprints.delCanvasItem');
}
/**
* post - delete the canvas item identified by the route id.
*
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
* @param string|null $id Canvas item id from the route
*/
#[RequiresPermission(BlueprintsPermissions::DELETE, entityScoped: true)]
public function post(?string $canvasSlug = null, ?string $id = null): Response
{
if ($this->template === null) {
return $this->tpl->displayPartial('errors.error404');
}
// The route id is optional in the pattern but mandatory in practice: every caller
// links with a concrete id. Validate strictly rather than sanitising — FILTER_SANITIZE_NUMBER_INT
// lets "1-2" through, which a later (int) cast would silently read as 1 and act on the
// wrong record. Fail closed instead.
$canvasId = filter_var($id, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($canvasId === false) {
return $this->tpl->displayPartial('errors.error404');
}
if ($this->request->has('del')) {
// The service resolves the item's REAL project and authorizes DELETE against it
// (throwing 403 for a missing/foreign item) — closing the by-id delete IDOR that
// the previous role-only Auth::authOrRedirect left open.
$this->blueprintsService->deleteCanvasItem($canvasId, $this->template->getDatabaseType());
$this->tpl->setNotification(
$this->language->__('notification.element_deleted'),
'success',
strtoupper($this->canvasSlug).'canvasitem_deleted'
);
return Frontcontroller::redirect(BASE_URL.'/blueprints/'.$this->canvasSlug.'/showCanvas');
}
$this->tpl->assign('canvasSlug', $this->canvasSlug);
$this->tpl->assign('id', $canvasId);
return $this->tpl->displayPartial('blueprints.delCanvasItem');
}
}

View File

@@ -0,0 +1,341 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Blueprints\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\Http\IncomingRequest;
use Leantime\Core\Language;
use Leantime\Core\UI\Template;
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
use Leantime\Domain\Notifications\Models\Notification as NotificationModel;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Symfony\Component\HttpFoundation\Response;
/**
* EditCanvasComment controller - handles the comment-focused editing view for canvas items.
*
* Replaces the old per-variant Canvas\Controllers\EditCanvasComment subclasses.
* The canvas type slug comes from the route instead of a class constant.
*
* All by-id item access goes through the Blueprints service, which authorizes against the
* item's real project — the controller never reads/writes canvas items via the repository.
*/
class EditCanvasComment
{
private string $canvasSlug;
private ?CanvasTemplate $template;
/**
* __construct - resolve dependencies and determine the canvas slug from request.
*
* @param IncomingRequest $request Incoming request
* @param Template $tpl Template engine
* @param Language $language Language service
* @param CommentRepository $commentsRepo Comment repository
* @param ProjectService $projectService Project service
* @param BlueprintsService $blueprintsService Blueprints service (project-authorized item CRUD)
* @param TemplateRegistry $templateRegistry Template registry
*/
public function __construct(
private IncomingRequest $request,
private Template $tpl,
private Language $language,
private CommentRepository $commentsRepo,
private ProjectService $projectService,
private BlueprintsService $blueprintsService,
TemplateRegistry $templateRegistry,
) {
$this->canvasSlug = strip_tags((string) ($request->route('canvasSlug') ?? ''));
$this->template = $templateRegistry->get($this->canvasSlug);
}
/**
* get - handle GET requests for the comment editing view.
*
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
* @param string|null $id Canvas item id from the route
*/
#[RequiresPermission(BlueprintsPermissions::VIEW, entityScoped: true)]
public function get(?string $canvasSlug = null, ?string $id = null): Response
{
$data = $this->request->getRequestParams();
if ($id !== null) {
$data['id'] = $id;
}
if ($this->template === null) {
return $this->tpl->displayPartial('errors.error404');
}
$canvasType = $this->template->getDatabaseType();
$commentModule = $this->template->getCommentModule();
$canvasTypes = $this->blueprintsService->getTranslatedBoxes($this->template);
$statusLabels = $this->blueprintsService->getTranslatedStatusLabels($this->template);
$relatesLabels = $this->blueprintsService->getTranslatedRelatesLabels($this->template);
if (isset($data['id'])) {
// Resolve + VIEW-authorize the item against its real project before anything else.
$canvasItem = $this->blueprintsService->getCanvasItem((int) $data['id'], $canvasType);
if (! $canvasItem) {
return $this->tpl->displayPartial('errors.error404');
}
// Delete comment — ONLY when it belongs to THIS gated item (module + moduleId).
// deleteComment() filters on the comment id alone, so without this bind a viewable
// item would let any global comment id be deleted (cross-item / cross-project).
if (isset($data['delComment']) === true) {
$commentId = (int) ($data['delComment']);
$comment = $this->commentsRepo->getComment($commentId);
if ($comment !== false
&& (string) $comment['module'] === $commentModule
&& (int) $comment['moduleId'] === (int) $canvasItem['id']) {
$this->commentsRepo->deleteComment($commentId);
$this->tpl->setNotification(
$this->language->__('notifications.comment_deleted'),
'success',
strtoupper($this->canvasSlug).'canvascomment_deleted'
);
}
}
$comments = $this->commentsRepo->getComments($commentModule, $canvasItem['id']);
$this->tpl->assign(
'numComments',
$this->commentsRepo->countComments($commentModule, $canvasItem['id'])
);
} else {
if (isset($data['type'])) {
$type = strip_tags($data['type']);
} else {
$type = array_key_first($canvasTypes);
}
$canvasItem = [
'id' => '',
'box' => $type,
'description' => '',
'status' => array_key_first($statusLabels),
'relates' => array_key_first($relatesLabels),
'assumptions' => '',
'data' => '',
'conclusion' => '',
'milestoneHeadline' => '',
'milestoneId' => '',
];
$comments = [];
}
$this->tpl->assign('comments', $comments);
$this->tpl->assign('canvasTypes', $canvasTypes);
$this->tpl->assign('canvasItem', $canvasItem);
$this->tpl->assign('canvasSlug', $this->canvasSlug);
return $this->tpl->displayPartial('blueprints.canvasComment');
}
/**
* post - handle POST requests for updating canvas items and adding comments.
*
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
* @param string|null $id Canvas item id from the route
*/
#[RequiresPermission(BlueprintsPermissions::EDIT, entityScoped: true)]
public function post(?string $canvasSlug = null, ?string $id = null): Response
{
$data = $this->request->getRequestParams();
if ($id !== null) {
$data['id'] = $id;
}
if ($this->template === null) {
return $this->tpl->displayPartial('errors.error404');
}
$canvasType = $this->template->getDatabaseType();
$commentModule = $this->template->getCommentModule();
$sessionKey = $this->template->getSessionKey();
$basePath = '/blueprints/'.$this->canvasSlug;
if (isset($data['changeItem'])) {
if (isset($data['itemId']) && $data['itemId'] != '') {
if (isset($data['description']) && ! empty($data['description'])) {
$currentCanvasId = (int) session($sessionKey);
$canvasItem = [
'box' => $data['box'],
'author' => session('userdata.id'),
'description' => $data['description'],
'status' => $data['status'],
'relates' => $data['relates'],
'assumptions' => $data['assumptions'],
'data' => $data['data'],
'conclusion' => $data['conclusion'],
'itemId' => $data['itemId'],
'id' => $data['itemId'],
'canvasId' => $currentCanvasId,
'milestoneId' => $data['milestoneId'],
'dependentMilstone' => '',
];
// Resolves the item's real project from itemId and authorizes EDIT there.
$this->blueprintsService->updateCanvasItem($canvasItem, $canvasType);
$comments = $this->commentsRepo->getComments($commentModule, $data['itemId']);
$this->tpl->assign('numComments', $this->commentsRepo->countComments(
$commentModule,
$data['itemId']
));
$this->tpl->assign('comments', $comments);
$this->tpl->setNotification(
$this->language->__('notifications.canvas_item_updates'),
'success',
strtoupper($this->canvasSlug).'canvasitem_updated'
);
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => BASE_URL.$basePath.'/editCanvasComment/'.(int) $data['itemId'],
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $canvasItem;
$notification->module = $this->canvasSlug.'canvas';
$notification->action = 'updated';
$notification->projectId = session('currentProject');
$notification->subject = $this->language->__('email_notifications.canvas_board_edited');
$notification->authorId = session('userdata.id');
$notification->message = sprintf(
$this->language->__('email_notifications.canvas_item_update_message'),
session('userdata.name'),
$canvasItem['description']
);
$this->projectService->notifyProjectUsers($notification);
return Frontcontroller::redirect(BASE_URL.$basePath.'/editCanvasComment/'.$data['itemId']);
} else {
$this->tpl->setNotification($this->language->__('notification.please_enter_element_title'), 'error');
}
} else {
if (isset($data['description']) && ! empty($data['description'])) {
$currentCanvasId = (int) session($sessionKey);
$canvasItem = [
'box' => $data['box'],
'author' => session('userdata.id'),
'description' => $data['description'],
'status' => $data['status'],
'relates' => $data['relates'],
'assumptions' => $data['assumptions'],
'data' => $data['data'],
'conclusion' => $data['conclusion'],
'canvasId' => $currentCanvasId,
];
// Resolves the target board's real project from canvasId and authorizes CREATE.
$id = $this->blueprintsService->createCanvasItem($canvasItem, $canvasType);
$canvasItem['id'] = $id;
$canvasTypes = $this->blueprintsService->getTranslatedBoxes($this->template);
$this->tpl->setNotification(
($canvasTypes[$data['box']]['title'] ?? $data['box']).' successfully created',
'success',
strtoupper($this->canvasSlug).'canvasitem_created'
);
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => BASE_URL.$basePath.'/editCanvasComment/'.(int) ($data['itemId'] ?? $id),
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $canvasItem;
$notification->module = $this->canvasSlug.'canvas';
$notification->action = 'created';
$notification->projectId = session('currentProject');
$notification->subject = $this->language->__('email_notifications.canvas_board_item_created');
$notification->authorId = session('userdata.id');
$notification->message = sprintf(
$this->language->__('email_notifications.canvas_item_created_message'),
session('userdata.name'),
$canvasItem['description']
);
$this->projectService->notifyProjectUsers($notification);
$this->tpl->setNotification(
$this->language->__('notification.element_created'),
'success',
strtoupper($this->canvasSlug).'canvasitem_created'
);
return Frontcontroller::redirect(BASE_URL.$basePath.'/editCanvasComment/'.$id);
} else {
$this->tpl->setNotification($this->language->__('notification.please_enter_element_title'), 'error');
}
}
}
if (isset($data['comment']) === true) {
$itemId = (int) ($data['id'] ?? 0);
// Only allow commenting on an item the user can view in their project.
if (! $this->blueprintsService->getCanvasItem($itemId, $canvasType)) {
return $this->tpl->displayPartial('errors.error404');
}
$values = [
'text' => $data['text'],
'date' => date('Y-m-d H:i:s'),
'userId' => (session('userdata.id')),
'moduleId' => $itemId,
'commentParent' => ($data['father']),
];
$this->commentsRepo->addComment($values, $commentModule);
$this->tpl->setNotification(
$this->language->__('notifications.comment_create_success'),
'success',
strtoupper($this->canvasSlug).'canvasitemcomment_created'
);
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => BASE_URL.$basePath.'/editCanvasComment/'.$itemId,
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $values;
$notification->module = $this->canvasSlug.'canvas';
$notification->action = 'commented';
$notification->projectId = session('currentProject');
$notification->subject = $this->language->__('email_notifications.canvas_board_comment_created');
$notification->authorId = session('userdata.id');
$notification->message = sprintf(
$this->language->__('email_notifications.canvas_item__comment_created_message'),
session('userdata.name')
);
$this->projectService->notifyProjectUsers($notification);
return Frontcontroller::redirect(BASE_URL.$basePath.'/editCanvasComment/'.$itemId);
}
$itemId = (int) ($data['id'] ?? 0);
$this->tpl->assign('id', $itemId);
$this->tpl->assign('canvasTypes', $this->blueprintsService->getTranslatedBoxes($this->template));
$this->tpl->assign('canvasItem', $this->blueprintsService->getCanvasItem($itemId, $canvasType));
$this->tpl->assign('canvasSlug', $this->canvasSlug);
return $this->tpl->displayPartial('blueprints.canvasComment');
}
}

View File

@@ -0,0 +1,427 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Blueprints\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\Http\IncomingRequest;
use Leantime\Core\Language;
use Leantime\Core\UI\Template;
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
use Leantime\Domain\Notifications\Models\Notification as NotificationModel;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Symfony\Component\HttpFoundation\Response;
/**
* EditCanvasItem controller - handles viewing and editing a single canvas item.
*
* Native Laravel controller: route-bound actions, the {canvasSlug}/{id} path segments
* arrive via the route (canvasSlug resolved in the constructor, id as a typed action arg),
* and request input is read from the injected IncomingRequest instead of the legacy
* merged-$params argument and superglobals.
*
* All by-id item access goes through the Blueprints service, which authorizes against the
* item's real project — the controller never reads/writes canvas items via the repository.
*/
class EditCanvasItem
{
private string $canvasSlug;
private ?CanvasTemplate $template;
/**
* __construct - resolve dependencies and the canvas template for the requested slug.
*
* @param IncomingRequest $request Incoming HTTP request
* @param Template $tpl Template engine
* @param Language $language Language service
* @param TicketService $ticketService Ticket service
* @param ProjectService $projectService Project service
* @param CommentRepository $commentsRepo Comments repository
* @param BlueprintsService $blueprintsService Blueprints service (project-authorized item CRUD)
* @param TemplateRegistry $templateRegistry Canvas template registry
*/
public function __construct(
private IncomingRequest $request,
private Template $tpl,
private Language $language,
private TicketService $ticketService,
private ProjectService $projectService,
private CommentRepository $commentsRepo,
private BlueprintsService $blueprintsService,
TemplateRegistry $templateRegistry,
) {
$this->canvasSlug = strip_tags((string) ($request->route('canvasSlug') ?? ''));
$this->template = $templateRegistry->get($this->canvasSlug);
}
/**
* get - handle GET requests for viewing/editing a canvas item.
*
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
* @param string|null $id Canvas item id
*/
#[RequiresPermission(BlueprintsPermissions::VIEW, entityScoped: true)]
public function get(?string $canvasSlug = null, ?string $id = null): Response
{
$data = $this->request->getRequestParams();
if ($id !== null) {
$data['id'] = $id;
}
if ($this->template === null) {
return $this->tpl->displayPartial('errors.error404');
}
$canvasType = $this->template->getDatabaseType();
$commentModule = $this->template->getCommentModule();
$canvasTypes = $this->blueprintsService->getTranslatedBoxes($this->template);
$statusLabels = $this->blueprintsService->getTranslatedStatusLabels($this->template);
$relatesLabels = $this->blueprintsService->getTranslatedRelatesLabels($this->template);
if (isset($data['id'])) {
// Resolve + VIEW-authorize the item against its real project BEFORE any mutation.
// false = missing / foreign project / unauthorized (indistinguishable -> no oracle).
$canvasItem = $this->blueprintsService->getCanvasItem((int) $data['id'], $canvasType);
if (! $canvasItem) {
return $this->tpl->displayPartial('errors.error404');
}
// Delete comment — ONLY when it actually belongs to THIS gated item (same module +
// moduleId). The item being viewable is not enough: deleteComment() filters on the
// comment id alone, so without this bind any global comment id (a comment on another
// item, canvas type, or project — one shared id sequence) could be deleted.
if (isset($data['delComment'])) {
$commentId = (int) ($data['delComment']);
$comment = $this->commentsRepo->getComment($commentId);
if ($comment !== false
&& (string) $comment['module'] === $commentModule
&& (int) $comment['moduleId'] === (int) $canvasItem['id']) {
$this->commentsRepo->deleteComment($commentId);
$this->tpl->setNotification($this->language->__('notifications.comment_deleted'), 'success');
}
}
// Delete milestone relationship — an EDIT, authorized by the service against the
// item's project (a view-only user is denied here).
if (isset($data['removeMilestone'])) {
$this->blueprintsService->patchCanvasItem((int) $data['id'], ['milestoneId' => ''], $canvasType);
$canvasItem = $this->blueprintsService->getCanvasItem((int) $data['id'], $canvasType);
$this->tpl->setNotification($this->language->__('notifications.milestone_detached'), 'success');
}
$comments = $this->commentsRepo->getComments($commentModule, $canvasItem['id']);
$this->tpl->assign(
'numComments',
$this->commentsRepo->countComments($commentModule, $canvasItem['id'])
);
} else {
if (isset($data['type'])) {
$type = strip_tags($data['type']);
} else {
$type = array_key_first($canvasTypes);
}
// Fall back to a known box when the requested type isn't part of this
// canvas, otherwise the dialog renders $canvasTypes[$type] on null (500).
if (! isset($canvasTypes[$type])) {
$type = array_key_first($canvasTypes);
}
$canvasItem = [
'id' => '',
'box' => $type,
'description' => '',
'status' => array_key_first($statusLabels),
'relates' => array_key_first($relatesLabels),
'assumptions' => '',
'data' => '',
'conclusion' => '',
'milestoneHeadline' => '',
'milestoneId' => '',
];
$comments = [];
}
$this->tpl->assign('comments', $comments);
$allProjectMilestones = $this->ticketService->getAllMilestones([
'sprint' => '',
'type' => 'milestone',
'currentProject' => session('currentProject'),
]);
$this->tpl->assign('milestones', $allProjectMilestones);
$this->tpl->assign('canvasItem', $canvasItem);
$this->tpl->assign('canvasSlug', $this->canvasSlug);
$this->tpl->assign('canvasIcon', $this->template->icon);
$this->tpl->assign('relatesLabels', $relatesLabels);
$this->tpl->assign('canvasTypes', $canvasTypes);
$this->tpl->assign('statusLabels', $statusLabels);
$this->tpl->assign('dataLabels', $this->blueprintsService->getTranslatedDataLabels($this->template));
return $this->tpl->displayPartial('blueprints.canvasDialog');
}
/**
* post - handle POST requests for creating/updating canvas items and comments.
*
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
* @param string|null $id Canvas item id
*/
#[RequiresPermission(BlueprintsPermissions::EDIT, entityScoped: true)]
public function post(?string $canvasSlug = null, ?string $id = null): Response
{
$data = $this->request->getRequestParams();
if ($id !== null) {
$data['id'] = $id;
}
if ($this->template === null) {
return $this->tpl->displayPartial('errors.error404');
}
$canvasType = $this->template->getDatabaseType();
$commentModule = $this->template->getCommentModule();
$sessionKey = $this->template->getSessionKey();
$basePath = '/blueprints/'.$this->canvasSlug;
if (isset($data['changeItem'])) {
if (isset($data['itemId']) && ! empty($data['itemId'])) {
if (isset($data['description']) && ! empty($data['description'])) {
$currentCanvasId = (int) session($sessionKey);
$canvasItem = [
'box' => $data['box'],
'author' => session('userdata.id'),
'description' => $data['description'],
'status' => $data['status'],
'relates' => $data['relates'],
'assumptions' => $data['assumptions'],
'data' => $data['data'],
'conclusion' => $data['conclusion'],
'itemId' => $data['itemId'],
'canvasId' => $currentCanvasId,
'milestoneId' => $data['milestoneId'],
'dependentMilstone' => '',
'id' => $data['itemId'],
];
if (isset($data['newMilestone']) && $data['newMilestone'] != '') {
$data['headline'] = $data['newMilestone'];
$data['tags'] = '#ccc';
$data['editFrom'] = dtHelper()->userNow()->formatDateForUser();
$data['editTo'] = dtHelper()->userNow()->addDays(7)->formatDateForUser();
$data['dependentMilestone'] = '';
$id = $this->ticketService->quickAddMilestone($data);
if ($id !== false) {
$canvasItem['milestoneId'] = $id;
}
}
if (isset($data['existingMilestone']) && $data['existingMilestone'] != '') {
$canvasItem['milestoneId'] = $data['existingMilestone'];
}
// Resolves the item's real project from itemId and authorizes EDIT there;
// the payload's canvasId can't relocate the item across projects.
$this->blueprintsService->updateCanvasItem($canvasItem, $canvasType);
$comments = $this->commentsRepo->getComments($commentModule, $data['itemId']);
$this->tpl->assign('numComments', $this->commentsRepo->countComments(
$commentModule,
$data['itemId']
));
$this->tpl->assign('comments', $comments);
$this->tpl->setNotification($this->language->__('notifications.canvas_item_updates'), 'success');
$subject = $this->language->__('email_notifications.canvas_board_edited');
$actualLink = BASE_URL.$basePath.'#/editCanvasItem/'.(int) $data['itemId'];
$message = sprintf(
$this->language->__('email_notifications.canvas_item_update_message'),
session('userdata.name'),
strip_tags($canvasItem['description'])
);
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => $actualLink,
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $canvasItem;
$notification->module = $this->canvasSlug.'canvas';
$notification->action = 'updated';
$notification->projectId = session('currentProject');
$notification->subject = $subject;
$notification->authorId = session('userdata.id');
$notification->message = $message;
$this->projectService->notifyProjectUsers($notification);
$closeModal = '';
if (isset($data['submitAction']) && $data['submitAction'] == 'closeModal') {
$closeModal = '?closeModal=true';
}
return Frontcontroller::redirect(BASE_URL.$basePath.'/editCanvasItem/'.$data['itemId'].$closeModal);
} else {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
}
} else {
if (isset($data['description']) && ! empty($data['description'])) {
$currentCanvasId = (int) session($sessionKey);
$canvasItem = [
'box' => $data['box'],
'author' => session('userdata.id'),
'description' => $data['description'],
'status' => $data['status'],
'relates' => $data['relates'],
'assumptions' => $data['assumptions'],
'data' => $data['data'],
'conclusion' => $data['conclusion'],
'canvasId' => $currentCanvasId,
];
// Resolves the TARGET board's real project from canvasId and authorizes
// CREATE there (the board must exist and belong to a project the user can
// create in) before inserting.
$id = $this->blueprintsService->createCanvasItem($canvasItem, $canvasType);
$canvasTypes = $this->blueprintsService->getTranslatedBoxes($this->template);
$this->tpl->setNotification(
$canvasTypes[$data['box']]['title'].' successfully created',
'success',
''.$data['box'].'_item_created'
);
$subject = $this->language->__('email_notifications.canvas_board_item_created');
$actualLink = BASE_URL.$basePath.'#/editCanvasItem/'.(int) ($data['itemId'] ?? $id);
$message = sprintf(
$this->language->__('email_notifications.canvas_item_created_message'),
session('userdata.name'),
strip_tags($canvasItem['description'])
);
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => $actualLink,
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $canvasItem;
$notification->module = $this->canvasSlug.'canvas';
$notification->action = 'created';
$notification->projectId = session('currentProject');
$notification->subject = $subject;
$notification->authorId = session('userdata.id');
$notification->message = $message;
$this->projectService->notifyProjectUsers($notification);
$this->tpl->setNotification($this->language->__('notification.element_created'), 'success');
$closeModal = '';
if (isset($data['submitAction']) && $data['submitAction'] == 'closeModal') {
$closeModal = '?closeModal=true';
}
return Frontcontroller::redirect(BASE_URL.$basePath.'/editCanvasItem/'.$id.$closeModal);
} else {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
}
}
}
if (isset($data['comment']) && isset($data['id'])) {
$itemId = (int) $data['id'];
// Only allow commenting on an item the user can view in their project.
if (! $this->blueprintsService->getCanvasItem($itemId, $canvasType)) {
return $this->tpl->displayPartial('errors.error404');
}
$values = [
'text' => $data['text'],
'date' => date('Y-m-d H:i:s'),
'userId' => (session('userdata.id')),
'moduleId' => $itemId,
'commentParent' => ($data['father']),
];
$commentId = $this->commentsRepo->addComment($values, $commentModule);
$this->tpl->setNotification($this->language->__('notifications.comment_create_success'), 'success');
$values['id'] = $commentId;
$subject = $this->language->__('email_notifications.canvas_board_comment_created');
$actualLink = BASE_URL.$basePath.'#/editCanvasItem/'.$itemId;
$message = sprintf(
$this->language->__('email_notifications.canvas_item__comment_created_message'),
session('userdata.name')
);
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => $actualLink,
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $values;
$notification->module = $this->canvasSlug.'canvas';
$notification->action = 'commented';
$notification->projectId = session('currentProject');
$notification->subject = $subject;
$notification->authorId = session('userdata.id');
$notification->message = $message;
$this->projectService->notifyProjectUsers($notification);
return Frontcontroller::redirect(BASE_URL.$basePath.'/editCanvasItem/'.$itemId);
}
$statusLabels = $this->blueprintsService->getTranslatedStatusLabels($this->template);
$relatesLabels = $this->blueprintsService->getTranslatedRelatesLabels($this->template);
$allProjectMilestones = $this->ticketService->getAllMilestones([
'sprint' => '',
'type' => 'milestone',
'currentProject' => session('currentProject'),
]);
$this->tpl->assign('milestones', $allProjectMilestones);
$this->tpl->assign('canvasTypes', $this->blueprintsService->getTranslatedBoxes($this->template));
$this->tpl->assign('statusLabels', $statusLabels);
$this->tpl->assign('relatesLabels', $relatesLabels);
$this->tpl->assign('dataLabels', $this->blueprintsService->getTranslatedDataLabels($this->template));
if (isset($data['id'])) {
$canvasItemId = (int) $data['id'];
$comments = $this->commentsRepo->getComments($commentModule, $canvasItemId);
$this->tpl->assign('canvasItem', $this->blueprintsService->getCanvasItem($canvasItemId, $canvasType));
} else {
$value = [
'id' => '',
'box' => $data['box'],
'author' => session('userdata.id'),
'description' => '',
'status' => array_key_first($statusLabels),
'relates' => array_key_first($relatesLabels),
'assumptions' => '',
'data' => '',
'conclusion' => '',
'milestoneHeadline' => '',
'milestoneId' => '',
];
$comments = [];
$this->tpl->assign('canvasItem', $value);
}
$this->tpl->assign('comments', $comments);
$this->tpl->assign('canvasSlug', $this->canvasSlug);
return $this->tpl->displayPartial('blueprints.canvasDialog');
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Blueprints\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Http\IncomingRequest;
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
use Leantime\Domain\Blueprints\Services\BlueprintsExport;
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
use Symfony\Component\HttpFoundation\Response;
/**
* Export controller - exports a blueprint canvas board as an XML file.
*
* Thin controller: resolves the board id and delegates XML generation to the
* BlueprintsExport service. The canvas type slug comes from the route.
*/
class Export
{
private string $canvasSlug;
private ?CanvasTemplate $template;
/**
* __construct - resolve dependencies and determine the canvas slug from the request.
*
* @param IncomingRequest $request Incoming request
* @param BlueprintsExport $exportService Blueprints export service
* @param TemplateRegistry $templateRegistry Template registry
*/
public function __construct(
IncomingRequest $request,
private BlueprintsExport $exportService,
TemplateRegistry $templateRegistry,
) {
$this->canvasSlug = strip_tags((string) ($request->route('canvasSlug') ?? ''));
$this->template = $templateRegistry->get($this->canvasSlug);
}
/**
* get - generate and return the XML export file.
*
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
* @param string|null $id Board id from the route
*/
#[RequiresPermission(BlueprintsPermissions::VIEW, entityScoped: true)]
public function get(?string $canvasSlug = null, ?string $id = null): Response
{
if ($this->template === null) {
return new Response('Unknown canvas type', 404);
}
// Resolve the board id from the route/query, falling back to the session.
$sessionKey = $this->template->getSessionKey();
if ($id !== null && $id !== '') {
$canvasId = (int) $id;
} elseif (session()->exists($sessionKey)) {
$canvasId = (int) session($sessionKey);
} else {
return new Response('', 204);
}
$exportData = $this->exportService->exportToXml($canvasId, $this->canvasSlug);
if ($exportData === null) {
return new Response('Canvas not found', 404);
}
clearstatcache();
$response = new Response($exportData);
$response->headers->set('Content-type', 'application/xml');
$response->headers->set(
'Content-Disposition',
'attachment; filename="'.$this->template->getDatabaseType().'-'.$canvasId.'.xml"'
);
$response->headers->set('Cache-Control', 'no-cache');
return $response;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Blueprints\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\UI\Template;
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
use Symfony\Component\HttpFoundation\Response;
/**
* ShowBoards controller - displays the blueprints boards overview.
*
* Absorbed from the former Strategy domain (there is no longer a separate
* "strategy" module). Native Laravel controller: a single route-bound get()
* action that reads the active project from the session and renders the
* recent + available boards overview.
*/
class ShowBoards
{
/**
* @param Template $tpl Template engine
* @param BlueprintsService $blueprintsService Blueprints service providing the boards overview
*/
public function __construct(
private Template $tpl,
private BlueprintsService $blueprintsService,
) {}
/**
* get - display the blueprints boards overview for the active project.
*
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*/
#[RequiresPermission(BlueprintsPermissions::VIEW)]
public function get(): Response
{
$overview = $this->blueprintsService->getBoardsOverview((int) session('currentProject'));
$this->tpl->assign('recentProgressCanvas', $overview['recentProgressCanvas']);
$this->tpl->assign('recentlyUpdatedCanvas', $overview['recentlyUpdatedCanvas']);
$this->tpl->assign('canvasProgress', $overview['canvasProgress']);
$this->tpl->assign('otherBoards', $overview['otherBoards']);
return $this->tpl->display('blueprints.showBoards');
}
}

View File

@@ -0,0 +1,380 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Blueprints\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\Http\IncomingRequest;
use Leantime\Core\Language;
use Leantime\Core\Mailer as MailerCore;
use Leantime\Core\UI\Template;
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
use Leantime\Domain\Blueprints\Repositories\Blueprints as BlueprintsRepository;
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Queue\Repositories\Queue as QueueRepository;
use Symfony\Component\HttpFoundation\Response;
/**
* ShowCanvas controller - displays and manages a blueprint canvas board.
*
* Replaces the old per-variant Canvas\Controllers\ShowCanvas subclasses.
* The canvas type slug comes from the route instead of a class constant.
*
* Native Laravel controller: route-bound actions, the {canvasSlug}/{id} path segments
* arrive via the route (canvasSlug resolved in the constructor, id as a typed action arg),
* and request input is read from the injected IncomingRequest instead of the legacy
* merged-$params argument and superglobals.
*/
class ShowCanvas
{
private string $canvasSlug;
private ?CanvasTemplate $template;
/**
* __construct - resolve dependencies and determine the canvas slug from request.
*
* @param IncomingRequest $request Incoming request
* @param Template $tpl Template engine
* @param Language $language Language service
* @param ProjectService $projectService Project service
* @param BlueprintsRepository $blueprintsRepo Blueprints repository
* @param BlueprintsService $blueprintsService Blueprints service
* @param TemplateRegistry $templateRegistry Template registry
*/
public function __construct(
private IncomingRequest $request,
private Template $tpl,
private Language $language,
private ProjectService $projectService,
private BlueprintsRepository $blueprintsRepo,
private BlueprintsService $blueprintsService,
TemplateRegistry $templateRegistry,
) {
$this->canvasSlug = strip_tags((string) ($request->route('canvasSlug') ?? ''));
$this->template = $templateRegistry->get($this->canvasSlug);
}
/**
* get - display the canvas board (and handle the board switcher).
*
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
* @param string|null $id Active board id from the route
*/
#[RequiresPermission(BlueprintsPermissions::VIEW, entityScoped: true)]
public function get(?string $canvasSlug = null, ?string $id = null): Response
{
$data = $this->request->getRequestParams();
if ($id !== null) {
$data['id'] = $id;
}
if ($this->template === null) {
return $this->tpl->displayPartial('errors.error404');
}
$canvasType = $this->template->getDatabaseType();
$sessionKey = $this->template->getSessionKey();
[$allCanvas, $currentCanvasId] = $this->resolveCurrentBoard($data, $canvasType, $sessionKey);
// Board switcher
if (isset($data['searchCanvas'])) {
session([$sessionKey => (int) $data['searchCanvas']]);
return Frontcontroller::redirect(BASE_URL.'/blueprints/'.$this->canvasSlug.'/showCanvas/');
}
return $this->renderCanvas($data, $allCanvas, $currentCanvasId);
}
/**
* post - handle create / edit / clone / merge / import board actions.
*
* @param string|null $canvasSlug Canvas type slug from the route (resolved in the constructor)
* @param string|null $id Active board id from the route
*/
#[RequiresPermission(BlueprintsPermissions::EDIT, entityScoped: true)]
public function post(?string $canvasSlug = null, ?string $id = null): Response
{
$data = $this->request->getRequestParams();
if ($id !== null) {
$data['id'] = $id;
}
if ($this->template === null) {
return $this->tpl->displayPartial('errors.error404');
}
$canvasType = $this->template->getDatabaseType();
$sessionKey = $this->template->getSessionKey();
[$allCanvas, $currentCanvasId] = $this->resolveCurrentBoard($data, $canvasType, $sessionKey);
// Add board
if (isset($data['newCanvas'])) {
if (isset($data['canvastitle']) && ! empty($data['canvastitle'])) {
if (! $this->blueprintsRepo->existCanvas(session('currentProject'), $data['canvastitle'], $canvasType)) {
$values = [
'title' => $data['canvastitle'],
'author' => session('userdata.id'),
'projectId' => session('currentProject'),
];
// createBoard authorizes CREATE against the target (current) project.
$currentCanvasId = $this->blueprintsService->createBoard($values, $canvasType);
$this->notifyBoardChange(
'email_notifications.canvas_created_message',
'notification.board_created',
$values['title']
);
$this->tpl->setNotification(
$this->language->__('notification.board_created'),
'success',
$this->canvasSlug.'board_created'
);
session([$sessionKey => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.'/blueprints/'.$this->canvasSlug.'/showCanvas/');
}
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
} else {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
}
}
// Edit board
if (isset($data['editCanvas']) && is_int($currentCanvasId) && $currentCanvasId > 0) {
if (isset($data['canvastitle']) && ! empty($data['canvastitle'])) {
if (! $this->blueprintsRepo->existCanvas(session('currentProject'), $data['canvastitle'], $canvasType)) {
// renameBoard authorizes EDIT against the board's real project.
$this->blueprintsService->renameBoard($currentCanvasId, $data['canvastitle'], $canvasType);
$this->tpl->setNotification($this->language->__('notification.board_edited'), 'success');
return $this->tpl->displayPartial('blueprints.boardDialog');
}
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
} else {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
}
}
// Clone board
if (isset($data['cloneCanvas']) && is_int($currentCanvasId) && $currentCanvasId > 0) {
if (isset($data['canvastitle']) && ! empty($data['canvastitle'])) {
if (! $this->blueprintsRepo->existCanvas(session('currentProject'), $data['canvastitle'], $canvasType)) {
// copyBoard authorizes VIEW on the source board's real project and CREATE
// on the target (current) project.
$currentCanvasId = $this->blueprintsService->copyBoard(
$currentCanvasId,
(int) session('currentProject'),
(int) session('userdata.id'),
$data['canvastitle'],
$canvasType
);
$this->tpl->setNotification($this->language->__('notification.board_copied'), 'success');
session([$sessionKey => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.'/blueprints/'.$this->canvasSlug.'/showCanvas/');
}
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
} else {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
}
}
// Merge board
if (isset($data['mergeCanvas']) && is_int($currentCanvasId) && $currentCanvasId > 0) {
if (isset($data['canvasid']) && $data['canvasid'] > 0) {
// mergeBoard authorizes EDIT on the target board's project and VIEW on the
// source board's project — both resolved by id, so neither can cross projects.
if ($this->blueprintsService->mergeBoard($currentCanvasId, (int) $data['canvasid'], $canvasType)) {
$this->tpl->setNotification($this->language->__('notification.board_merged'), 'success');
return Frontcontroller::redirect(BASE_URL.'/blueprints/'.$this->canvasSlug.'/showCanvas/');
}
$this->tpl->setNotification($this->language->__('notification.merge_error'), 'error');
} else {
$this->tpl->setNotification($this->language->__('notification.internal_error'), 'error');
}
}
// Import board
if (isset($data['importCanvas']) && isset($_FILES['canvasfile']) && $_FILES['canvasfile']['error'] === 0) {
$uploadfile = tempnam(sys_get_temp_dir(), 'leantime.').'.xml';
if (move_uploaded_file($_FILES['canvasfile']['tmp_name'], $uploadfile)) {
$importCanvasId = $this->blueprintsService->import(
$uploadfile,
$this->canvasSlug,
projectId: session('currentProject'),
authorId: session('userdata.id')
);
unlink($uploadfile);
if ($importCanvasId !== false) {
session([$sessionKey => $importCanvasId]);
$canvas = $this->blueprintsService->getBoard((int) $importCanvasId, $canvasType);
$this->notifyBoardChange(
'email_notifications.canvas_imported_message',
'notification.board_imported',
$canvas !== false ? ($canvas[0]['title'] ?? '') : ''
);
$this->tpl->setNotification($this->language->__('notification.board_imported'), 'success');
return Frontcontroller::redirect(BASE_URL.'/blueprints/'.$this->canvasSlug.'/showCanvas/');
}
}
$this->tpl->setNotification($this->language->__('notification.board_import_failed'), 'error');
}
return $this->renderCanvas($data, $allCanvas, $currentCanvasId);
}
/**
* resolveCurrentBoard - load the project's boards and determine the active board id.
*
* Creates a default board if none exist, validates the session board against the
* available boards, and honours an explicit id from the request.
*
* @param array<string, mixed> $params Request parameters
* @param string $canvasType Database canvas type
* @param string $sessionKey Session key for the active board
* @return array{0: array<int, array<string, mixed>>, 1: int} [allCanvas, currentCanvasId]
*/
private function resolveCurrentBoard(array $params, string $canvasType, string $sessionKey): array
{
$allCanvas = $this->blueprintsRepo->getAllCanvas(session('currentProject'), $canvasType);
// Create a default board when the project has none.
if (! $allCanvas) {
$this->blueprintsRepo->addCanvas([
'title' => $this->language->__('label.board'),
'author' => session('userdata.id'),
'projectId' => session('currentProject'),
], $canvasType);
$allCanvas = $this->blueprintsRepo->getAllCanvas(session('currentProject'), $canvasType);
}
$currentCanvasId = -1;
if (session()->exists($sessionKey)) {
// Cast: DB drivers (MySQL emulated prepares) return ids as strings.
$currentCanvasId = (int) session($sessionKey);
$found = false;
foreach ($allCanvas as $row) {
if ($currentCanvasId == $row['id']) {
$found = true;
break;
}
}
if (! $found) {
$currentCanvasId = -1;
session([$sessionKey => '']);
}
} else {
session([$sessionKey => '']);
}
if (count($allCanvas) > 0 && session($sessionKey) == '') {
$currentCanvasId = (int) $allCanvas[0]['id'];
session([$sessionKey => $currentCanvasId]);
}
if (isset($params['id'])) {
// Only honor an explicit board id that belongs to the CURRENT project's boards
// ($allCanvas is project-scoped). A foreign/unknown id must not become the active
// board — otherwise renderCanvas would read another project's items (IDOR).
$requestedId = (int) $params['id'];
$projectBoardIds = array_map(static fn ($row) => (int) $row['id'], $allCanvas);
if (in_array($requestedId, $projectBoardIds, true)) {
$currentCanvasId = $requestedId;
session([$sessionKey => $currentCanvasId]);
}
}
return [$allCanvas, $currentCanvasId];
}
/**
* renderCanvas - assign template data and render the canvas board page.
*
* @param array<string, mixed> $params Request parameters
* @param array<int, array<string, mixed>> $allCanvas All boards for the project
* @param int $currentCanvasId Active board id
*/
private function renderCanvas(array $params, array $allCanvas, int $currentCanvasId): Response
{
$filter['status'] = $params['filter_status'] ?? (session('filter_status') ?? 'all');
session(['filter_status' => $filter['status']]);
$filter['relates'] = $params['filter_relates'] ?? (session('filter_relates') ?? 'all');
session(['filter_relates' => $filter['relates']]);
$this->tpl->assign('filter', $filter);
$this->tpl->assign('currentCanvas', $currentCanvasId);
$this->tpl->assign('canvasSlug', $this->canvasSlug);
$this->tpl->assign('template', $this->template);
$this->tpl->assign('canvasIcon', $this->template->icon);
$this->tpl->assign('canvasTypes', $this->blueprintsService->getTranslatedBoxes($this->template));
$this->tpl->assign('statusLabels', $this->blueprintsService->getTranslatedStatusLabels($this->template));
$this->tpl->assign('relatesLabels', $this->blueprintsService->getTranslatedRelatesLabels($this->template));
$this->tpl->assign('dataLabels', $this->blueprintsService->getTranslatedDataLabels($this->template));
$this->tpl->assign('disclaimer', $this->blueprintsService->getTranslatedDisclaimer($this->template));
$this->tpl->assign('allCanvas', $allCanvas);
// getBoardItems authorizes VIEW against the board's real project and returns [] for a
// foreign/unknown board, so a board id from another project can't leak its items here.
$this->tpl->assign('canvasItems', $this->blueprintsService->getBoardItems($currentCanvasId, $this->template->getDatabaseType(), $this->template->getCommentModule()));
$this->tpl->assign('users', $this->projectService->getUsersAssignedToProject(session('currentProject')));
return $this->tpl->display('blueprints.showCanvas');
}
/**
* notifyBoardChange - email + queue notify project users about a board change.
*
* @param string $messageKey i18n key for the email body (sprintf: user name, board link/title)
* @param string $subjectKey i18n key for the subject/queue title
* @param string $boardTitle Board title
*/
private function notifyBoardChange(string $messageKey, string $subjectKey, string $boardTitle): void
{
$mailer = app()->make(MailerCore::class);
$users = $this->projectService->getUsersToNotify(session('currentProject'));
$mailer->setSubject($this->language->__($subjectKey));
$message = sprintf(
$this->language->__($messageKey),
session('userdata.name'),
"<a href='".CURRENT_URL."'>".strip_tags($boardTitle).'</a>'
);
$mailer->setHtml($message);
$queue = app()->make(QueueRepository::class);
$queue->queueMessageToUsers(
$users,
$message,
$this->language->__($subjectKey),
session('currentProject')
);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Leantime\Domain\Blueprints\Events;
use Leantime\Core\Events\Concerns\InteractsWithEvents;
use Leantime\Core\Events\Contracts\LeantimeEvent;
/**
* Fired after a canvas item was updated — for ANY canvas type (goal, idea,
* wiki, logic model, …), since all canvas items share the zp_canvas_items
* table and the same update chokepoints.
*
* The event is deliberately generic: consumers that only care about a specific
* canvas (e.g. the strategy Logic Model, which propagates edits down to the
* work it generated) resolve the item's canvas and filter themselves.
* `changedFields` is best-effort and never authoritative: for patches it is
* the set of allow-listed keys that were written (which can include a key set
* to the value it already held), and for full updates it is over-inclusive
* (every mirrored column). Either way it says "possibly touched", not
* "definitely changed", so listeners must still no-op when the field they
* mirror is actually unchanged.
*/
final class CanvasItemUpdated implements LeantimeEvent
{
use InteractsWithEvents;
/**
* @param int $canvasItemId The updated canvas item id.
* @param array<int, string> $changedFields Best-effort names of the fields possibly
* written (see class doc); not authoritative.
* @param string|null $legacyHook TEMPORARY (migration window): the emitting method name —
* pass __FUNCTION__ — used to rebuild the historical string
* name for legacy string-based listeners.
*/
public function __construct(
public readonly int $canvasItemId,
public readonly array $changedFields = [],
private readonly ?string $legacyHook = null,
) {}
/**
* The exact historical string name of the emitting site. Remove with the migration window.
*
* @return array<int, string>
*/
public function legacyHooks(): array
{
if ($this->legacyHook === null) {
return [];
}
return ['leantime.domain.blueprints.services.blueprints.'.$this->legacyHook.'.canvas_item_updated'];
}
}

View File

@@ -0,0 +1,262 @@
leantime.blueprintsController = (function () {
var canvasName = '';
var setCanvasName = function (name) {
canvasName = name;
};
var setRowHeights = function () {
// Collect all unique row IDs from .canvas-row elements
var rowIds = [];
jQuery(".canvas-row[id]").each(function () {
var id = jQuery(this).attr("id");
if (id && rowIds.indexOf(id) === -1) {
rowIds.push(id);
}
});
if (rowIds.length === 0) {
return;
}
var nbRows = rowIds.length;
var rowHeight = jQuery("html").height() - 320 - 20 * nbRows - 25;
var perRowHeight = rowHeight / nbRows;
// For each row, find the tallest content and set all columns to that height
for (var i = 0; i < rowIds.length; i++) {
var rowSelector = "#" + rowIds[i];
var maxHeight = perRowHeight;
jQuery(rowSelector + " div.contentInner").each(function () {
if (jQuery(this).height() > maxHeight) {
maxHeight = jQuery(this).height() + 50;
}
});
jQuery(rowSelector + " .column .contentInner").css("height", maxHeight);
}
};
var initFilterBar = function () {
jQuery(window).bind("load", function () {
jQuery(".loading").fadeOut();
jQuery(".filterBar .row-fluid").css("opacity", "1");
});
};
var initCanvasLinks = function () {
jQuery(".addCanvasLink").nyroModal();
jQuery(".editCanvasLink").click(function () {
jQuery('#editCanvas').modal('show');
});
jQuery(".cloneCanvasLink").click(function () {
jQuery('#cloneCanvas').modal('show');
});
jQuery(".mergeCanvasLink").click(function () {
jQuery('#mergeCanvas').modal('show');
});
jQuery(".importCanvasLink").click(function () {
jQuery('#importCanvas').modal('show');
});
};
var closeModal = false;
//Variables
var canvasoptions = function () {
return {
sizes: {
minW: 700,
minH: 1000,
},
resizable: true,
autoSizable: true,
callbacks: {
beforeShowCont: function () {
jQuery(".showDialogOnLoad").show();
if (closeModal == true) {
closeModal = false;
location.reload();
}
},
afterShowCont: function () {
window.htmx.process('.nyroModalCont');
jQuery(".blueprintsCanvasModal, #commentForm, #commentForm .deleteComment, .blueprintsCanvasMilestone .deleteMilestone").nyroModal(canvasoptions());
},
beforeClose: function () {
location.reload();
}
},
titleFromIframe: true
};
};
//Functions
var _initModals = function () {
jQuery(".blueprintsCanvasModal, #commentForm, #commentForm .deleteComment, .blueprintsCanvasMilestone .deleteMilestone").nyroModal(canvasoptions());
};
var openModalManually = function (url) {
jQuery.nmManual(url, canvasoptions());
};
var toggleMilestoneSelectors = function (trigger) {
if (trigger == 'existing') {
jQuery('#newMilestone, #milestoneSelectors').hide('fast');
jQuery('#existingMilestone').show();
_initModals();
}
if (trigger == 'new') {
jQuery('#newMilestone').show();
jQuery('#existingMilestone, #milestoneSelectors').hide('fast');
_initModals();
}
if (trigger == 'hide') {
jQuery('#newMilestone, #existingMilestone').hide('fast');
jQuery('#milestoneSelectors').show('fast');
}
};
var setCloseModal = function () {
closeModal = true;
};
var initUserDropdown = function () {
jQuery("body").on(
"click",
".userDropdown .dropdown-menu a",
function () {
var dataValue = jQuery(this).attr("data-value").split("_");
var dataLabel = jQuery(this).attr('data-label');
if (dataValue.length == 3) {
var canvasId = dataValue[0];
var userId = dataValue[1];
var profileImageId = dataValue[2];
jQuery.ajax(
{
type: 'PATCH',
url: leantime.appUrl + '/api/blueprints/' + canvasName,
data:
{
id : canvasId,
author: userId
}
}
).done(
function () {
jQuery("#userDropdownMenuLink" + canvasId + " span.text span#userImage" + canvasId + " img").attr("src", leantime.appUrl + "/users/profileImage/" + encodeURIComponent(userId));
jQuery.growl({message: leantime.i18n.__("short_notifications.user_updated"), style: "success"});
}
);
}
}
);
};
var initStatusDropdown = function () {
jQuery("body").on(
"click",
".statusDropdown .dropdown-menu a",
function () {
var dataValue = jQuery(this).attr("data-value").split("/");
var dataLabel = jQuery(this).attr('data-label');
if (dataValue.length == 2) {
var canvasItemId = dataValue[0];
var status = dataValue[1];
var statusClass = jQuery(this).attr('class');
jQuery.ajax(
{
type: 'PATCH',
url: leantime.appUrl + '/api/blueprints/' + canvasName,
data:
{
id : canvasItemId,
status: status
}
}
).done(
function () {
jQuery("#statusDropdownMenuLink" + canvasItemId + " span.text").text(dataLabel);
jQuery("#statusDropdownMenuLink" + canvasItemId).removeClass().addClass(statusClass + " dropdown-toggle f-left status ");
jQuery.growl({message: leantime.i18n.__("short_notifications.status_updated")});
}
);
}
}
);
};
var initRelatesDropdown = function () {
jQuery("body").on(
"click",
".relatesDropdown .dropdown-menu a",
function () {
var dataValue = jQuery(this).attr("data-value").split("/");
var dataLabel = jQuery(this).attr('data-label');
if (dataValue.length == 2) {
var canvasItemId = dataValue[0];
var relates = dataValue[1];
var relatesClass = jQuery(this).attr('class');
jQuery.ajax(
{
type: 'PATCH',
url: leantime.appUrl + '/api/blueprints/' + canvasName,
data:
{
id : canvasItemId,
relates: relates
}
}
).done(
function () {
jQuery("#relatesDropdownMenuLink" + canvasItemId + " span.text").text(dataLabel);
jQuery("#relatesDropdownMenuLink" + canvasItemId).removeClass().addClass(relatesClass + " dropdown-toggle f-left relates ");
jQuery.growl({message: leantime.i18n.__("short_notifications.relates_updated")});
}
);
}
}
);
};
// Make public what you want to have public, everything else is private
return {
setCanvasName: setCanvasName,
setRowHeights: setRowHeights,
initFilterBar: initFilterBar,
initCanvasLinks: initCanvasLinks,
initUserDropdown: initUserDropdown,
initStatusDropdown: initStatusDropdown,
initRelatesDropdown: initRelatesDropdown,
setCloseModal: setCloseModal,
toggleMilestoneSelectors: toggleMilestoneSelectors,
openModalManually: openModalManually
};
})();

View File

@@ -0,0 +1,144 @@
<?php
namespace Leantime\Domain\Blueprints\Models;
class CanvasTemplate
{
public string $slug;
public string $icon;
public string $disclaimer;
public int $minColumns;
public int $minWidthOffset;
public array $boxes;
public array $statusLabels;
public array $relatesLabels;
public array $dataLabels;
public array $layout;
/**
* Optional ContentTemplates key (see app/Domain/ContentTemplates).
*
* When set, freshly-created boards of this canvas type auto-apply the
* referenced content template's items, giving the user a non-empty
* starting point. Looked up against the registry as
* forAppliesTo($this->slug)[$startContent].
*
* Null when the blueprint ships no starter content (the default).
*/
public ?string $startContent;
private const DEFAULT_STATUS_LABELS = [
'status_draft' => ['icon' => 'fa-circle-question', 'color' => 'blue', 'title' => 'status.draft', 'dropdown' => 'info', 'active' => true],
'status_review' => ['icon' => 'fa-circle-exclamation', 'color' => 'orange', 'title' => 'status.review', 'dropdown' => 'warning', 'active' => true],
'status_valid' => ['icon' => 'fa-circle-check', 'color' => 'green', 'title' => 'status.valid', 'dropdown' => 'success', 'active' => true],
'status_hold' => ['icon' => 'fa-circle-h', 'color' => 'red', 'title' => 'status.hold', 'dropdown' => 'danger', 'active' => true],
'status_invalid' => ['icon' => 'fa-circle-xmark', 'color' => 'red', 'title' => 'status.invalid', 'dropdown' => 'danger', 'active' => true],
];
private const DEFAULT_RELATES_LABELS = [
'relates_none' => ['icon' => 'fa-border-none', 'color' => 'grey', 'title' => 'relates.none', 'dropdown' => 'default', 'active' => true],
'relates_customers' => ['icon' => 'fa-users', 'color' => 'green', 'title' => 'relates.customers', 'dropdown' => 'success', 'active' => true],
'relates_offerings' => ['icon' => 'fa-barcode', 'color' => 'red', 'title' => 'relates.offerings', 'dropdown' => 'danger', 'active' => true],
'relates_capabilities' => ['icon' => 'fa-pen-ruler', 'color' => 'blue', 'title' => 'relates.capabilities', 'dropdown' => 'info', 'active' => true],
'relates_financials' => ['icon' => 'fa-money-bill', 'color' => 'yellow', 'title' => 'relates.financials', 'dropdown' => 'warning', 'active' => true],
'relates_markets' => ['icon' => 'fa-shop', 'color' => 'brown', 'title' => 'relates.markets', 'dropdown' => 'default', 'active' => true],
'relates_environment' => ['icon' => 'fa-tree', 'color' => 'darkgreen', 'title' => 'relates.environment', 'dropdown' => 'default', 'active' => true],
'relates_firm' => ['icon' => 'fa-building', 'color' => 'darkblue', 'title' => 'relates.firm', 'dropdown' => 'info', 'active' => true],
];
private const DEFAULT_DATA_LABELS = [
1 => ['title' => 'label.assumptions', 'field' => 'assumptions', 'active' => true],
2 => ['title' => 'label.data', 'field' => 'data', 'active' => true],
3 => ['title' => 'label.conclusion', 'field' => 'conclusion', 'active' => true],
];
/**
* @param array<string, mixed> $data Parsed YAML data
*/
public function __construct(array $data)
{
$this->slug = $data['slug'];
$this->icon = $data['icon'] ?? 'fa-x';
$this->disclaimer = $data['disclaimer'] ?? '';
$this->minColumns = $data['minColumns'] ?? 2;
$this->minWidthOffset = $data['minWidthOffset'] ?? 0;
$this->boxes = $data['boxes'] ?? [];
$this->layout = $data['layout'] ?? [];
$this->startContent = isset($data['startContent']) && $data['startContent'] !== ''
? (string) $data['startContent']
: null;
$this->statusLabels = $this->resolveLabels($data, 'statusLabels', self::DEFAULT_STATUS_LABELS);
$this->relatesLabels = $this->resolveLabels($data, 'relatesLabels', self::DEFAULT_RELATES_LABELS);
$this->dataLabels = $this->resolveDataLabels($data);
}
/**
* @return string Database type value (e.g., "swotcanvas")
*/
public function getDatabaseType(): string
{
return $this->slug.'canvas';
}
/**
* @return string Comment module identifier (e.g., "swotcanvasitem")
*/
public function getCommentModule(): string
{
return $this->slug.'canvasitem';
}
/**
* @return string Session key for tracking current board
*/
public function getSessionKey(): string
{
return 'current'.strtoupper($this->slug).'Canvas';
}
/**
* @param array<string, mixed> $data Parsed YAML data
* @param string $key Label key
* @param array<string, mixed> $defaults Default labels
* @return array<string, mixed>
*/
private function resolveLabels(array $data, string $key, array $defaults): array
{
if (! array_key_exists($key, $data)) {
return $defaults;
}
if ($data[$key] === null || $data[$key] === 'default') {
return $defaults;
}
return $data[$key];
}
/**
* @param array<string, mixed> $data Parsed YAML data
* @return array<int, array<string, mixed>>
*/
private function resolveDataLabels(array $data): array
{
if (! array_key_exists('dataLabels', $data)) {
return self::DEFAULT_DATA_LABELS;
}
if ($data['dataLabels'] === null || $data['dataLabels'] === 'default') {
return self::DEFAULT_DATA_LABELS;
}
return $data['dataLabels'];
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Leantime\Domain\Blueprints\Permissions;
use Leantime\Core\Auth\Permissions\Permission;
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
/**
* The Blueprints (canvas) permission vocabulary — the verbs only.
*
* Blueprints is the consolidated canvas system: every canvas variant (SWOT, Lean, Value, …)
* is a row in the shared `zp_canvas`/`zp_canvas_items` tables, distinguished by a `type`
* column, and each board belongs to exactly one project. Capabilities are therefore
* PROJECT-scoped (projectScoped = true, the default) — evaluated against the user's role IN
* the board's project.
*
* One vocabulary covers the whole canvas family (Blueprints + the deprecated Canvas shim, and
* later Goalcanvas/Logicmodelcanvas): a "canvas" capability is the same regardless of variant.
*
* The standard verbs auto-grant via the central matrix (readonly = view; editor =
* create/edit/delete; manager+ = all), so no {@see \Leantime\Core\Auth\Permissions\DefaultRolePermissions}
* change is required.
*/
final class BlueprintsPermissions implements ProvidesPermissions
{
public const VIEW = 'blueprints.view';
public const CREATE = 'blueprints.create';
public const EDIT = 'blueprints.edit';
public const DELETE = 'blueprints.delete';
public function domain(): string
{
return 'blueprints';
}
public function permissions(): array
{
return [
new Permission(self::VIEW, 'View canvas boards'),
new Permission(self::CREATE, 'Create canvas boards and items'),
new Permission(self::EDIT, 'Edit canvas boards and items'),
new Permission(self::DELETE, 'Delete canvas boards and items'),
];
}
}

View File

@@ -0,0 +1,703 @@
<?php
namespace Leantime\Domain\Blueprints\Repositories;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Db\DatabaseHelper;
use Leantime\Core\Db\Db as DbCore;
use Leantime\Core\Db\Repository;
use Leantime\Domain\Tickets\Repositories\Tickets;
/**
* @api
*/
class Blueprints extends Repository
{
/**
* Columns on zp_canvas_items that may be written via patchCanvasItem().
* Acts as a mass-assignment allowlist for the inline-update API.
*/
private const PATCHABLE_COLUMNS = [
'title', 'description', 'assumptions', 'data', 'conclusion',
'box', 'status', 'relates', 'milestoneId', 'kpi', 'data1',
'startDate', 'endDate', 'setting', 'metricType', 'startValue',
'currentValue', 'endValue', 'impact', 'effort', 'probability',
'action', 'assignedTo', 'parent', 'tags', 'sortindex',
'why_this_matters', 'starting_picture',
];
protected ConnectionInterface $connection;
protected DatabaseHelper $dbHelper;
private Tickets $ticketRepo;
/**
* @param DbCore $db Database connection
* @param Tickets $ticketRepo Ticket repository
* @param DatabaseHelper $dbHelper Database helper
*/
public function __construct(
DbCore $db,
Tickets $ticketRepo,
DatabaseHelper $dbHelper
) {
$this->connection = $db->getConnection();
$this->ticketRepo = $ticketRepo;
$this->dbHelper = $dbHelper;
}
/**
* @param int $projectId Project ID
* @param string $canvasType Database type (e.g., "swotcanvas")
* @return false|array<int, array<string, mixed>>
*/
public function getAllCanvas(int $projectId, string $canvasType): false|array
{
$results = $this->connection->table('zp_canvas')
->select([
'zp_canvas.id',
'zp_canvas.title',
'zp_canvas.author',
'zp_canvas.created',
'zp_canvas.description',
't1.firstname as authorFirstname',
't1.lastname as authorLastname',
])
->selectRaw('COUNT(zp_canvas_items.id) AS '.$this->dbHelper->wrapColumn('boxItems'))
->leftJoin('zp_user as t1', 'zp_canvas.author', '=', 't1.id')
->leftJoin('zp_canvas_items', 'zp_canvas.id', '=', 'zp_canvas_items.canvasId')
->where('type', $canvasType)
->where('projectId', $projectId)
->groupBy(['zp_canvas.id', 'zp_canvas.title', 'zp_canvas.created', 'zp_canvas.author', 'zp_canvas.description', 't1.firstname', 't1.lastname'])
->orderBy('zp_canvas.title')
->orderBy('zp_canvas.created')
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
/**
* @param int $canvasId Canvas board ID
* @param string $canvasType Database type (e.g., "swotcanvas")
* @return false|array<int, array<string, mixed>>
*/
public function getSingleCanvas(int $canvasId, string $canvasType): false|array
{
$results = $this->connection->table('zp_canvas')
->select([
'zp_canvas.id',
'zp_canvas.title',
'zp_canvas.author',
'zp_canvas.created',
'zp_canvas.projectId',
't1.firstname as authorFirstname',
't1.lastname as authorLastname',
])
->leftJoin('zp_user as t1', 'zp_canvas.author', '=', 't1.id')
->where('type', $canvasType)
->where('zp_canvas.id', $canvasId)
->orderBy('zp_canvas.title')
->orderBy('zp_canvas.created')
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
/**
* Resolve the project id a canvas ITEM ultimately belongs to (item → board → project),
* optionally constrained to a canvas $canvasType. Returns null when the item does not exist
* OR its board is of a different type.
*
* This is a fail-CLOSED primitive: the service layer uses it to authorize by-id item
* operations against the item's REAL project, never the caller's session project. A
* `null` return must be treated as "deny" — never as "fall back to currentProject".
*
* @param int $itemId Canvas item id
* @param string|null $canvasType Constrain to this board type (e.g. "swotcanvas"); null = any type
*/
public function getCanvasItemProjectId(int $itemId, ?string $canvasType = null): ?int
{
$query = $this->connection->table('zp_canvas_items')
->leftJoin('zp_canvas', 'zp_canvas.id', '=', 'zp_canvas_items.canvasId')
->where('zp_canvas_items.id', $itemId);
if ($canvasType !== null) {
$query->where('zp_canvas.type', $canvasType);
}
$projectId = $query->value('zp_canvas.projectId');
return $projectId !== null ? (int) $projectId : null;
}
/**
* Resolve the project id a canvas BOARD belongs to, optionally constrained to a canvas
* $canvasType. Returns null when the board does not exist OR is of a different type.
*
* Fail-CLOSED companion to {@see getCanvasItemProjectId()} for by-id board operations.
*
* @param int $canvasId Canvas board id
* @param string|null $canvasType Constrain to this board type; null = any type
*/
public function getCanvasProjectId(int $canvasId, ?string $canvasType = null): ?int
{
$query = $this->connection->table('zp_canvas')
->where('id', $canvasId);
if ($canvasType !== null) {
$query->where('type', $canvasType);
}
$projectId = $query->value('projectId');
return $projectId !== null ? (int) $projectId : null;
}
/**
* @param int $id Canvas board ID
*/
public function deleteCanvas(int $id): void
{
$this->connection->table('zp_canvas_items')
->where('canvasId', $id)
->delete();
$this->connection->table('zp_canvas')
->where('id', $id)
->delete();
}
/**
* @param array<string, mixed> $values Canvas values
* @param string $canvasType Database type (e.g., "swotcanvas")
*/
public function addCanvas(array $values, string $canvasType): false|string
{
$insertId = $this->connection->table('zp_canvas')->insertGetId([
'title' => $values['title'],
'description' => $values['description'] ?? '',
'author' => $values['author'],
'created' => now(),
'type' => $canvasType,
'projectId' => $values['projectId'],
]);
return (string) $insertId;
}
/**
* @param array<string, mixed> $values Canvas values
*/
public function updateCanvas(array $values): mixed
{
return $this->connection->table('zp_canvas')
->where('id', $values['id'])
->update([
'title' => $values['title'],
'description' => $values['description'] ?? '',
]);
}
/**
* @param array<string, mixed> $values Item values
*/
public function editCanvasItem(array $values): void
{
$this->connection->table('zp_canvas_items')
->where('id', $values['itemId'] ?? $values['id'])
->update([
'title' => $values['title'] ?? '',
'description' => $values['description'],
'assumptions' => $values['assumptions'] ?? '',
'data' => $values['data'] ?? '',
'conclusion' => $values['conclusion'] ?? '',
'modified' => now(),
'status' => $values['status'] ?? '',
'relates' => $values['relates'] ?? '',
'milestoneId' => $values['milestoneId'] ?? '',
'kpi' => $values['kpi'] ?? '',
'data1' => $values['data1'] ?? '',
'startDate' => $values['startDate'] ?? '',
'endDate' => $values['endDate'] ?? '',
'setting' => $values['setting'] ?? '',
'metricType' => $values['metricType'] ?? '',
'startValue' => $values['startValue'] ?? '',
'currentValue' => $values['currentValue'] ?? '',
'endValue' => $values['endValue'] ?? '',
'impact' => $values['impact'] ?? '',
'effort' => $values['effort'] ?? '',
'probability' => $values['probability'] ?? '',
'action' => $values['action'] ?? '',
'assignedTo' => $values['assignedTo'] ?? '',
'parent' => $values['parent'] ?? '',
'tags' => $values['tags'] ?? '',
]);
}
/**
* @param int $id Item ID
* @param array<string, mixed> $params Fields to patch
*/
public function patchCanvasItem(int $id, array $params): bool
{
$updates = [];
foreach ($params as $key => $value) {
if (in_array($key, self::PATCHABLE_COLUMNS, true)) {
$updates[$key] = $value;
}
}
if (empty($updates)) {
return false;
}
return (bool) $this->connection->table('zp_canvas_items')
->where('id', $id)
->update($updates);
}
/**
* @param int $id Canvas board ID
* @param string $commentModule Comment module name (e.g., "swotcanvasitem")
* @return false|array<int, array<string, mixed>>
*/
public function getCanvasItemsById(int $id, string $commentModule): false|array
{
$statusGroups = $this->ticketRepo->getStatusListGroupedByType(session('currentProject'));
$results = $this->connection->table('zp_canvas_items')
->select([
'zp_canvas_items.id',
'zp_canvas_items.description',
'zp_canvas_items.assumptions',
'zp_canvas_items.data',
'zp_canvas_items.conclusion',
'zp_canvas_items.box',
'zp_canvas_items.author',
'zp_canvas_items.created',
'zp_canvas_items.modified',
'zp_canvas_items.canvasId',
'zp_canvas_items.sortindex',
'zp_canvas_items.status',
'zp_canvas_items.relates',
'zp_canvas_items.milestoneId',
'zp_canvas_items.parent',
'zp_canvas_items.title',
'zp_canvas_items.tags',
'zp_canvas_items.kpi',
'zp_canvas_items.data1',
'zp_canvas_items.data2',
'zp_canvas_items.data3',
'zp_canvas_items.data4',
'zp_canvas_items.data5',
'zp_canvas_items.startDate',
'zp_canvas_items.endDate',
'zp_canvas_items.setting',
'zp_canvas_items.metricType',
'zp_canvas_items.startValue',
'zp_canvas_items.currentValue',
'zp_canvas_items.endValue',
'zp_canvas_items.impact',
'zp_canvas_items.effort',
'zp_canvas_items.probability',
'zp_canvas_items.action',
'zp_canvas_items.assignedTo',
'zp_canvas_items.why_this_matters',
'zp_canvas_items.starting_picture',
't1.firstname as authorFirstname',
't1.lastname as authorLastname',
't1.profileId as authorProfileId',
'milestone.headline as milestoneHeadline',
'milestone.editTo as milestoneEditTo',
])
->selectRaw('COUNT(DISTINCT zp_comment.id) AS '.$this->dbHelper->wrapColumn('commentCount'))
->selectRaw('0 AS '.$this->dbHelper->wrapColumn('percentDone'))
->leftJoin('zp_user as t1', 'zp_canvas_items.author', '=', 't1.id')
->leftJoin('zp_tickets as milestone', function ($join) {
$join->on('zp_canvas_items.milestoneId', '=', $this->connection->raw($this->dbHelper->castAs($this->dbHelper->wrapColumn('milestone.id'), 'text')));
})
->leftJoin('zp_comment', function ($join) use ($commentModule) {
$join->on('zp_canvas_items.id', '=', 'zp_comment.moduleId')
->where('zp_comment.module', '=', $commentModule);
})
->where('zp_canvas_items.canvasId', $id)
->groupBy(['zp_canvas_items.id', 'zp_canvas_items.box', 'zp_canvas_items.sortindex', 't1.firstname', 't1.lastname', 't1.profileId', 'milestone.headline', 'milestone.editTo'])
->orderBy('zp_canvas_items.box')
->orderBy('zp_canvas_items.sortindex')
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
/**
* @param int $id Canvas item ID
*/
public function getSingleCanvasItem(int $id): mixed
{
$statusGroups = $this->ticketRepo->getStatusListGroupedByType(session('currentProject'));
$result = $this->connection->table('zp_canvas_items')
->select([
'zp_canvas_items.id',
'zp_canvas_items.title',
'zp_canvas_items.description',
'zp_canvas_items.assumptions',
'zp_canvas_items.data',
'zp_canvas_items.conclusion',
'zp_canvas_items.box',
'zp_canvas_items.author',
'zp_canvas_items.created',
'zp_canvas_items.modified',
'zp_canvas_items.canvasId',
'zp_canvas_items.sortindex',
'zp_canvas_items.status',
'zp_canvas_items.relates',
'zp_canvas_items.milestoneId',
'zp_canvas_items.kpi',
'zp_canvas_items.data1',
'zp_canvas_items.data2',
'zp_canvas_items.data3',
'zp_canvas_items.data4',
'zp_canvas_items.data5',
'zp_canvas_items.startDate',
'zp_canvas_items.endDate',
'zp_canvas_items.setting',
'zp_canvas_items.metricType',
'zp_canvas_items.startValue',
'zp_canvas_items.currentValue',
'zp_canvas_items.endValue',
'zp_canvas_items.impact',
'zp_canvas_items.effort',
'zp_canvas_items.probability',
'zp_canvas_items.action',
'zp_canvas_items.assignedTo',
'zp_canvas_items.parent',
'zp_canvas_items.tags',
'board.title as boardTitle',
'parentKPI.description as parentKPIDescription',
'parentGoal.title as parentGoalDescription',
't1.firstname as authorFirstname',
't1.lastname as authorLastname',
'milestone.headline as milestoneHeadline',
'milestone.editTo as milestoneEditTo',
])
->selectRaw('COUNT('.$this->dbHelper->wrapColumn('progressTickets.id').') AS '.$this->dbHelper->wrapColumn('allTickets'))
->selectSub(function ($query) use ($statusGroups) {
$progressSubId = $this->dbHelper->wrapColumn('progressSub.id');
$progressSubStatus = $this->dbHelper->wrapColumn('progressSub.status');
$progressSubStorypoints = $this->dbHelper->wrapColumn('progressSub.storypoints');
$query->from('zp_tickets as progressSub')
->selectRaw('(
CASE WHEN
COUNT(DISTINCT '.$progressSubId.') > 0
THEN
ROUND(
(
SUM(CASE WHEN '.$progressSubStatus.' '.$statusGroups['DONE'].' THEN CASE WHEN '.$progressSubStorypoints.' = 0 THEN 3 ELSE '.$progressSubStorypoints.' END ELSE 0 END) /
SUM(CASE WHEN '.$progressSubStorypoints.' = 0 THEN 3 ELSE '.$progressSubStorypoints.' END)
) *100)
ELSE
0
END)
')
->whereColumn(
$this->connection->raw($this->dbHelper->castAs($this->dbHelper->wrapColumn('progressSub.milestoneid'), 'text')),
'=',
'zp_canvas_items.milestoneId'
)
->where('progressSub.type', '<>', 'milestone');
}, 'percentDone')
->leftJoin('zp_canvas_items as parentKPI', 'zp_canvas_items.kpi', '=', 'parentKPI.id')
->leftJoin('zp_canvas as board', 'board.id', '=', 'zp_canvas_items.canvasId')
->leftJoin('zp_canvas_items as parentGoal', 'zp_canvas_items.parent', '=', 'parentGoal.id')
->leftJoin('zp_tickets as progressTickets', function ($join) {
$join->on(
$this->connection->raw($this->dbHelper->castAs($this->dbHelper->wrapColumn('progressTickets.milestoneid'), 'text')),
'=',
'zp_canvas_items.milestoneId'
)
->where('progressTickets.type', '<>', 'milestone')
->where('progressTickets.type', '<>', 'subtask');
})
->leftJoin('zp_tickets as milestone', function ($join) {
$join->on('zp_canvas_items.milestoneId', '=', $this->connection->raw($this->dbHelper->castAs($this->dbHelper->wrapColumn('milestone.id'), 'text')));
})
->leftJoin('zp_user as t1', 'zp_canvas_items.author', '=', 't1.id')
->where('zp_canvas_items.id', $id)
->groupBy([
'zp_canvas_items.id',
'board.title',
'parentKPI.description',
'parentGoal.title',
't1.firstname',
't1.lastname',
'milestone.headline',
'milestone.editTo',
])
->first();
if ($result !== null && $result->id != null) {
return (array) $result;
} else {
return false;
}
}
/**
* @param array<string, mixed> $values Item values
*/
public function addCanvasItem(array $values): false|string
{
$id = $this->connection->table('zp_canvas_items')->insertGetId([
'description' => $values['description'] ?? '',
'title' => $values['title'] ?? '',
'assumptions' => $values['assumptions'] ?? '',
'data' => $values['data'] ?? '',
'conclusion' => $values['conclusion'] ?? '',
'box' => $values['box'],
'author' => $values['author'],
'created' => now(),
'modified' => now(),
'canvasId' => $values['canvasId'],
'status' => $values['status'] ?? '',
'relates' => $values['relates'] ?? '',
'milestoneId' => $values['milestoneId'] ?? '',
'kpi' => $values['kpi'] ?? '',
'data1' => $values['data1'] ?? '',
'startDate' => $values['startDate'] ?? '',
'endDate' => $values['endDate'] ?? '',
'setting' => $values['setting'] ?? '',
'metricType' => $values['metricType'] ?? '',
'impact' => $values['impact'] ?? '',
'effort' => $values['effort'] ?? '',
'probability' => $values['probability'] ?? '',
'action' => $values['action'] ?? '',
'assignedTo' => $values['assignedTo'] ?? '',
'startValue' => $values['startValue'] ?? '',
'currentValue' => $values['currentValue'] ?? '',
'endValue' => $values['endValue'] ?? '',
'parent' => $values['parent'] ?? '',
'tags' => $values['tags'] ?? '',
]);
return (string) $id;
}
/**
* @param int $id Item ID
*/
public function delCanvasItem(int $id): void
{
$this->connection->table('zp_canvas_items')
->where('id', $id)
->delete();
}
/**
* @param int|null $projectId Project ID
* @param string $canvasType Database type (e.g., "swotcanvas")
*/
public function getNumberOfCanvasItems(?int $projectId, string $canvasType): mixed
{
$query = $this->connection->table('zp_canvas_items')
->selectRaw('COUNT(zp_canvas_items.id) AS '.$this->dbHelper->wrapColumn('canvasCount'))
->leftJoin('zp_canvas as canvasBoard', 'zp_canvas_items.canvasId', '=', 'canvasBoard.id')
->where('canvasBoard.type', $canvasType);
if (! is_null($projectId)) {
$query->where('canvasBoard.projectId', $projectId);
}
$result = $query->first();
return $result->canvasCount ?? 0;
}
/**
* @param int|null $projectId Project ID
* @param string $canvasType Database type (e.g., "swotcanvas")
*/
public function getNumberOfBoards(?int $projectId, string $canvasType): mixed
{
$query = $this->connection->table('zp_canvas')
->selectRaw('COUNT(zp_canvas.id) AS '.$this->dbHelper->wrapColumn('boardCount'))
->where('zp_canvas.type', $canvasType);
if (! is_null($projectId)) {
$query->where('zp_canvas.projectId', $projectId);
}
$result = $query->first();
return $result->boardCount ?? 0;
}
/**
* @param int $projectId Project ID
* @param string $canvasTitle Canvas title
* @param string $canvasType Database type (e.g., "swotcanvas")
*/
public function existCanvas(int $projectId, string $canvasTitle, string $canvasType): bool
{
$result = $this->connection->table('zp_canvas')
->selectRaw('COUNT(id) as '.$this->dbHelper->wrapColumn('nbCanvas'))
->where('projectId', $projectId)
->where('title', $canvasTitle)
->where('type', $canvasType)
->first();
return isset($result->nbCanvas) && $result->nbCanvas > 0;
}
/**
* @param int $projectId Project ID
* @param int $canvasId Source canvas ID
* @param int $authorId Author ID
* @param string $canvasTitle New canvas title
* @param string $canvasType Database type (e.g., "swotcanvas")
* @return int New canvas ID
*/
public function copyCanvas(int $projectId, int $canvasId, int $authorId, string $canvasTitle, string $canvasType): int
{
$values = ['title' => $canvasTitle, 'author' => $authorId, 'projectId' => $projectId];
$newCanvasId = $this->addCanvas($values, $canvasType);
$columns = [
'title', 'description', 'assumptions', 'data', 'conclusion', 'box', 'author',
'created', 'modified', 'canvasId', 'status', 'relates', 'milestoneId', 'kpi',
'data1', 'startDate', 'endDate', 'setting', 'metricType', 'impact', 'effort',
'probability', 'action', 'assignedTo', 'startValue', 'currentValue', 'endValue',
];
$selectQuery = $this->connection->table('zp_canvas_items')
->select([
'title', 'description', 'assumptions', 'data', 'conclusion', 'box', 'author',
])
->selectRaw($this->dbHelper->currentTimestamp().' as created')
->selectRaw($this->dbHelper->currentTimestamp().' as modified')
->selectRaw('? as '.$this->dbHelper->wrapColumn('canvasId'), [$newCanvasId])
->select(['status', 'relates'])
->selectRaw("'' as ".$this->dbHelper->wrapColumn('milestoneId'))
->select([
'kpi', 'data1', 'startDate', 'endDate', 'setting', 'metricType', 'impact',
'effort', 'probability', 'action', 'assignedTo', 'startValue', 'currentValue', 'endValue',
])
->where('canvasId', $canvasId);
$this->connection->table('zp_canvas_items')->insertUsing($columns, $selectQuery);
return (int) $newCanvasId;
}
/**
* @param int $canvasId Target canvas ID
* @param int $mergeId Source canvas ID
*/
public function mergeCanvas(int $canvasId, int $mergeId): bool
{
$columns = [
'title', 'description', 'assumptions', 'data', 'conclusion', 'box', 'author',
'created', 'modified', 'canvasId', 'status', 'relates', 'milestoneId', 'kpi',
'data1', 'startDate', 'endDate', 'setting', 'metricType', 'impact', 'effort',
'probability', 'action', 'assignedTo', 'startValue', 'currentValue', 'endValue',
];
$selectQuery = $this->connection->table('zp_canvas_items')
->select([
'title', 'description', 'assumptions', 'data', 'conclusion', 'box', 'author',
])
->selectRaw($this->dbHelper->currentTimestamp().' as created')
->selectRaw($this->dbHelper->currentTimestamp().' as modified')
->selectRaw('? as '.$this->dbHelper->wrapColumn('canvasId'), [$canvasId])
->select(['status', 'relates'])
->selectRaw("'' as ".$this->dbHelper->wrapColumn('milestoneId'))
->select([
'kpi', 'data1', 'startDate', 'endDate', 'setting', 'metricType', 'impact',
'effort', 'probability', 'action', 'assignedTo', 'startValue', 'currentValue', 'endValue',
])
->where('canvasId', $mergeId);
$this->connection->table('zp_canvas_items')->insertUsing($columns, $selectQuery);
return true;
}
/**
* @param int $projectId Project ID
* @param array<int, string> $boards Board types to query
* @return array<int, array<string, mixed>>|bool
*/
public function getCanvasProgressCount(int $projectId, array $boards): array|bool
{
$query = $this->connection->table('zp_canvas')
->select([
'zp_canvas.id as canvasId',
'zp_canvas.type as canvasType',
'zp_canvas_items.box',
])
->selectRaw('COUNT(zp_canvas_items.id) AS '.$this->dbHelper->wrapColumn('boxItems'))
->leftJoin('zp_canvas_items', 'zp_canvas.id', '=', 'zp_canvas_items.canvasId');
if ($projectId != '') {
$query->where('projectId', $projectId);
}
if (count($boards) > 0) {
$query->whereIn('type', $boards);
}
$results = $query->groupBy(['zp_canvas.id', 'zp_canvas.type', 'zp_canvas_items.box'])
->orderBy('zp_canvas.title')
->orderBy('zp_canvas.created')
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
/**
* @param int $projectId Project ID
* @param array<int, string> $boards Board types to query
* @return array<int, array<string, mixed>>
*/
public function getLastUpdatedCanvas(int $projectId, array $boards): array
{
$query = $this->connection->table('zp_canvas')
->select([
'zp_canvas.id as id',
'zp_canvas.type as type',
'zp_canvas.title as title',
])
->selectRaw('COALESCE(MAX(zp_canvas_items.modified), zp_canvas.created) AS modified')
->leftJoin('zp_canvas_items', 'zp_canvas.id', '=', 'zp_canvas_items.canvasId');
if ($projectId > 0) {
$query->where('projectId', $projectId);
}
if (count($boards) > 0) {
$query->whereIn('type', $boards);
}
$results = $query->groupBy(['zp_canvas.id', 'zp_canvas.type', 'zp_canvas.title', 'zp_canvas.created'])
->orderByDesc('modified')
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
/**
* @param int $projectId Project ID
* @return array<int, array<string, mixed>>
*/
public function getTags(int $projectId): array
{
$results = $this->connection->table('zp_canvas_items')
->select('zp_canvas_items.tags')
->leftJoin('zp_canvas', 'zp_canvas.id', '=', 'zp_canvas_items.canvasId')
->where('zp_canvas.projectId', $projectId)
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
}

View File

@@ -0,0 +1,992 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Blueprints\Services;
use DOMDocument;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Facades\Log;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Domains\BaseService;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Core\Language as LanguageCore;
use Leantime\Domain\Blueprints\Events\CanvasItemUpdated;
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
use Leantime\Domain\Blueprints\Permissions\BlueprintsPermissions;
use Leantime\Domain\Blueprints\Repositories\Blueprints as BlueprintsRepository;
use Leantime\Domain\ContentTemplates\Services\ContentTemplateRegistry;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
/**
* Blueprints service - business logic for unified canvas boards.
*
* Replaces the old Canvas\Services\Canvas by using the Blueprints repo
* and TemplateRegistry directly instead of dynamically resolving variant repos.
*
* Authorization: canvas boards and items are PROJECT-scoped (each board belongs to one
* project; items belong to a board). Every by-id board/item operation routes through this
* service, which resolves the entity's REAL project via the repository's fail-closed
* resolvers and authorizes the matching {@see BlueprintsPermissions} verb against it. A
* resolver returning null (missing id, or an id whose board is a different canvas type — the
* shared `zp_canvas`/`zp_canvas_items` tables hold every variant under one id sequence) is
* treated as DENY, never as "fall back to the session project". Controllers therefore call
* these methods instead of the repository directly.
*
* @api
*/
class Blueprints extends BaseService
{
private BlueprintsRepository $blueprintsRepo;
private TemplateRegistry $templateRegistry;
private LanguageCore $language;
private ContentTemplateRegistry $contentTemplates;
/**
* @param BlueprintsRepository $blueprintsRepo Blueprints repository
* @param TemplateRegistry $templateRegistry Canvas template registry
* @param LanguageCore $language Language service for translations
* @param ContentTemplateRegistry $contentTemplates Content templates registry — used to auto-apply a blueprint's optional startContent on board creation
*/
public function __construct(
BlueprintsRepository $blueprintsRepo,
TemplateRegistry $templateRegistry,
LanguageCore $language,
ContentTemplateRegistry $contentTemplates,
) {
$this->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<string, mixed>|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<int, array<string, mixed>>|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<int, array<string, mixed>>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed> $payload
* @return array<int, string>
*/
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<string, mixed> $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<string, mixed> $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<int, string> $boards Array of database canvas types to check
* @return array<string, float> 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<int, string> $boards Array of database canvas types to filter by
* @return array<int, array<string, mixed>> 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<string, array<string, mixed>> 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<string, array<string, mixed>> 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<string, array<string, mixed>> 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<int, array<string, mixed>> 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<string, array<string, string>> 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<int, string> 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<int, array<string, mixed>> $recentlyUpdatedCanvas Canvas rows ordered by last updated item.
* @param array<string, array<string, string>> $boardMetadata Board type keyed metadata map (passed by reference so the consumed types are removed).
* @return array<string, array<string, mixed>> 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<string, array<string, mixed>>, otherBoards: array<string, array<string, string>>, recentlyUpdatedCanvas: array<int, array<string, mixed>>, canvasProgress: array<string, float|string>} 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,
];
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Blueprints\Services;
/**
* BlueprintsExport service - builds the XML export for a blueprint canvas board.
*
* The XML generation used to live in the Export controller; it is business logic
* and belongs in the service layer so the controller stays thin.
*
* @api
*/
class BlueprintsExport
{
/**
* @param Blueprints $blueprintsService Blueprints service (VIEW-authorized board reads + label translation)
* @param TemplateRegistry $templateRegistry Canvas template registry
*/
public function __construct(
private Blueprints $blueprintsService,
private TemplateRegistry $templateRegistry,
) {}
/**
* exportToXml - generate the XML document for a canvas board.
*
* @param int $canvasId Canvas board identifier
* @param string $canvasSlug Canvas type slug (e.g. "swot")
* @return string|null XML document, or null if the canvas type or board does not exist,
* or the user cannot view it (export is reachable via JSON-RPC with
* an arbitrary board id, so the VIEW authorization happens here).
*
* @api
*/
public function exportToXml(int $canvasId, string $canvasSlug): ?string
{
$template = $this->templateRegistry->get($canvasSlug);
if ($template === null) {
return null;
}
$canvasType = $template->getDatabaseType();
// getBoard authorizes VIEW against the board's real project and returns false for a
// missing/foreign/unauthorized board — so a foreign id is indistinguishable from a
// non-existent one (no cross-project existence oracle).
$canvasAry = $this->blueprintsService->getBoard($canvasId, $canvasType);
if ($canvasAry === false || empty($canvasAry)) {
return null;
}
$records = $this->blueprintsService->getBoardItems($canvasId, $canvasType, $template->getCommentModule());
$canvasTypes = $this->blueprintsService->getTranslatedBoxes($template);
$xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>'.PHP_EOL.PHP_EOL;
$xml .= $this->buildXml($canvasType, $canvasAry[0]['title'], $records, $canvasTypes);
return $xml;
}
/**
* buildXml - generate XML markup for canvas data.
*
* @param string $canvasKey Database canvas type (e.g. "swotcanvas")
* @param string $canvasTitle Canvas board title
* @param array<int, array<string, mixed>> $records Canvas item records
* @param array<string, array<string, mixed>> $canvasTypes Translated box definitions
* @param int $indent Indent level
* @return string XML data
*/
private function buildXml(string $canvasKey, string $canvasTitle, array $records, array $canvasTypes, int $indent = 0): string
{
$is = str_repeat(' ', 4 * $indent);
$tab = str_repeat(' ', 4);
$xml = $is.'<canvas key="'.$canvasKey.'">'.PHP_EOL;
$xml .= $is.$tab.'<title>'.$canvasTitle.'</title>'.PHP_EOL;
$xml .= $is.$tab.'<content>'.PHP_EOL;
foreach ($canvasTypes as $key => $data) {
$xml .= $is.$tab.$tab.'<element key="'.$key.'">'.PHP_EOL;
foreach ($records as $record) {
if ($record['box'] === $key) {
$xml .= $is.$tab.$tab.$tab.'<item>'.PHP_EOL;
$xml .= $is.$tab.$tab.$tab.$tab.'<created>'.($record['created'] ?? '').'</created>'.PHP_EOL;
$xml .= $is.$tab.$tab.$tab.$tab.'<modified>'.($record['modified'] ?? '').'</modified>'.PHP_EOL;
$xml .= $is.$tab.$tab.$tab.$tab.'<author id="'.$record['author'].'" firstname="'.($record['authorFirstname'] ?? '').'" '.
'lastname="'.($record['authorLastname'] ?? '').'"/>'.PHP_EOL;
$xml .= $is.$tab.$tab.$tab.$tab.'<description>'.($record['description'] ?? '').'</description>'.PHP_EOL;
$xml .= $is.$tab.$tab.$tab.$tab.'<status key="'.($record['status'] ?? '').'" />'.PHP_EOL;
$xml .= $is.$tab.$tab.$tab.$tab.'<relates key="'.($record['relates'] ?? '').'" />'.PHP_EOL;
$xml .= $is.$tab.$tab.$tab.$tab.'<assumptions>'.($record['assumptions'] ?? '').'</assumptions>'.PHP_EOL;
$xml .= $is.$tab.$tab.$tab.$tab.'<data>'.($record['data'] ?? '').'</data>'.PHP_EOL;
$xml .= $is.$tab.$tab.$tab.$tab.'<conclusion>'.($record['conclusion'] ?? '').'</conclusion>'.PHP_EOL;
$xml .= $is.$tab.$tab.$tab.'</item>'.PHP_EOL;
}
}
$xml .= $is.$tab.$tab.'</element>'.PHP_EOL;
}
$xml .= $is.$tab.'</content>'.PHP_EOL;
$xml .= $is.'</canvas>'.PHP_EOL;
return $xml;
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace Leantime\Domain\Blueprints\Services;
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
use Symfony\Component\Yaml\Yaml;
class TemplateRegistry
{
/** @var array<string, CanvasTemplate|null> */
private array $templates = [];
private string $definitionsPath;
public function __construct()
{
$this->definitionsPath = APP_ROOT.'/app/Domain/Blueprints/Templates/definitions';
}
/**
* @param string $slug Canvas type slug (e.g., "swot", "lean")
*/
public function get(string $slug): ?CanvasTemplate
{
$slug = strtolower(trim($slug));
if (array_key_exists($slug, $this->templates)) {
return $this->templates[$slug];
}
$path = $this->definitionsPath.'/'.$slug.'.yaml';
if (! file_exists($path)) {
$this->templates[$slug] = null;
return null;
}
$data = Yaml::parseFile($path);
$template = new CanvasTemplate($data);
$this->templates[$slug] = $template;
return $template;
}
/**
* @return array<string, CanvasTemplate>
*/
public function all(): array
{
$this->loadAll();
return array_filter($this->templates);
}
/**
* @return list<string>
*/
public function slugs(): array
{
return array_keys($this->all());
}
/**
* @param string $dbType Database type value (e.g., "swotcanvas")
*/
public function getByDatabaseType(string $dbType): ?CanvasTemplate
{
// Strip a trailing "canvas" suffix only — a naive str_replace would
// corrupt any type whose name embeds the word (e.g., "canvassing").
// Require something *before* the suffix so the bare "canvas" type
// resolves to itself, not an empty slug; -strlen() avoids a brittle -6.
$suffix = 'canvas';
$slug = str_ends_with($dbType, $suffix) && strlen($dbType) > strlen($suffix)
? substr($dbType, 0, -strlen($suffix))
: $dbType;
return $this->get($slug);
}
private function loadAll(): void
{
$files = glob($this->definitionsPath.'/*.yaml');
if ($files === false) {
return;
}
foreach ($files as $file) {
$slug = basename($file, '.yaml');
if (! array_key_exists($slug, $this->templates)) {
$this->get($slug);
}
}
}
}

View File

@@ -0,0 +1,25 @@
@php
$canvasTitle = $canvasTitle ?? '';
$canvasSlug = $canvasSlug ?? '';
@endphp
<form action="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/boardDialog{{ isset($_GET['id']) ? '/' . (int) $_GET['id'] : '' }}" method="post" class="formModal">
<div class="modal-header">
<h4 class="modal-title"><i class='fa fa-plus'></i> {!! __('subtitles.create_new_board') !!}</h4>
</div>
<div class="modal-body">
<label>{!! __('label.title_new') !!}</label><br />
<x-global::forms.text-input name="canvastitle" value="{{ $canvasTitle }}" placeholder="{{ __('input.placeholders.enter_title_for_board') }}"
style="width: 100%" />
</div>
<div class="modal-footer">
@if(isset($_GET['id']))
<input type="hidden" name="editCanvas" value="{{ (int) $_GET['id'] }}">
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save_board')" name="editCanvas" />
@else
<input type="hidden" name="newCanvas" value="true">
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.create_board')" name="newCanvas" />
@endif
<x-global::forms.button inputType="button" contentRole="tertiary" onclick="jQuery.nmTop().close();">{!! __('buttons.close') !!}</x-global::forms.button>
</div>
</form>

View File

@@ -0,0 +1,54 @@
@php
$canvasSlug = $canvasSlug ?? '';
$canvasItem = $canvasItem ?? ['id' => '', 'box' => '', 'description' => ''];
$canvasTypes = $canvasTypes ?? [];
$id = '';
if (isset($canvasItem['id']) && $canvasItem['id'] != '') {
$id = $canvasItem['id'];
}
@endphp
<script type="text/javascript">
window.onload = function() {
if (!window.jQuery) {
//It's not a modal
location.href="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/showCanvas?showModal={{ $canvasItem['id'] }}";
}
}
</script>
<div class="showDialogOnLoad" style="display:none;">
<h4 class="widgettitle title-light" style="padding-bottom: 0"><i class="fas {{ $canvasTypes[$canvasItem['box']]['icon'] ?? '' }}"></i> {{ $canvasTypes[$canvasItem['box']]['title'] ?? '' }}</h4>
<hr style="margin-top: 5px; margin-bottom: 15px;">
{!! $tpl->displayNotification() !!}
<h5 style="padding-left: 40px"><strong>{{ $canvasItem['description'] }}</strong></h5>
@if($id !== '')
<br />
<input type="hidden" name="comment" value="1" />
<h4 class="widgettitle title-light"><span class="fa fa-comments"></span>{!! __('subtitles.discussion') !!}</h4>
@include('comments::submodules.generalComment', ['formUrl' => '/blueprints/' . $canvasSlug . '/editCanvasComment/' . $id])
@endif
</div>
<script type="text/javascript">
jQuery(document).ready(function(){
if (window.leantime && window.leantime.tiptapController) {
leantime.tiptapController.initSimpleEditor();
}
@if(! $login::userIsAtLeast($roles::$editor))
leantime.authController.makeInputReadonly(".nyroModalCont");
@endif
@if($login::userHasRole([$roles::$commenter]))
leantime.commentsController.enableCommenterForms();
@endif
})
</script>

View File

@@ -0,0 +1,229 @@
@php
$canvasSlug = $canvasSlug ?? '';
$currentCanvas = $currentCanvas ?? '';
$canvasItem = $canvasItem ?? ['id' => '', 'box' => '', 'description' => '', 'status' => '', 'relates' => '', 'milestoneId' => '', 'milestoneHeadline' => ''];
$canvasTypes = $canvasTypes ?? [];
$hiddenStatusLabels = $statusLabels ?? [];
$statusLabels = $statusLabels ?? [];
$hiddenRelatesLabels = $relatesLabels ?? [];
$relatesLabels = $relatesLabels ?? [];
$dataLabels = $dataLabels ?? [1 => ['active' => false, 'field' => '', 'title' => ''], 2 => ['active' => false, 'field' => '', 'title' => ''], 3 => ['active' => false, 'field' => '', 'title' => '']];
$milestones = $milestones ?? [];
$users = $users ?? [];
$searchCriteria = $searchCriteria ?? [];
$id = '';
if (isset($canvasItem['id']) && $canvasItem['id'] != '') {
$id = $canvasItem['id'];
}
$boxMeta = $canvasTypes[$canvasItem['box']] ?? ['icon' => '', 'title' => ''];
@endphp
<script type="text/javascript">
window.onload = function() {
if (!window.jQuery) {
//It's not a modal
location.href="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/showCanvas?showModal={{ $canvasItem['id'] }}";
}
}
</script>
<div class="" style="width:900px;">
<h4 class="widgettitle title-light" style="padding-bottom: 0"><i class="fas {{ $boxMeta['icon'] }}"></i> {{ $boxMeta['title'] }}</h4>
<hr style="margin-top: 5px; margin-bottom: 15px;">
{!! $tpl->displayNotification() !!}
<form class="formModal" method="post" action="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/editCanvasItem/{{ $id }}">
<input type="hidden" value="{{ $currentCanvas }}" name="canvasId" />
<input type="hidden" value="{{ $canvasItem['box'] }}" name="box" id="box"/>
<input type="hidden" value="{{ $id }}" name="itemId" id="itemId"/>
<label>{!! __('label.description') !!}</label>
<x-global::forms.text-input name="description" value="{{ $canvasItem['description'] }}" style="width:100%" /><br />
@if(! empty($statusLabels))
<label>{!! __('label.status') !!}</label>
<select name="status" style="width: 50%" id="statusCanvas">
</select><br /><br />
@else
<input type="hidden" name="status" value="{{ $canvasItem['status'] ?? array_key_first($hiddenStatusLabels) }}" />
@endif
@if(! empty($relatesLabels))
<label>{!! __('label.relates') !!}</label>
<select name="relates" style="width: 50%" id="relatesCanvas">
</select><br />
@else
<input type="hidden" name="relates" value="{{ $canvasItem['relates'] ?? array_key_first($hiddenRelatesLabels) }}" />
@endif
@if($dataLabels[1]['active'])
<label>{!! __($dataLabels[1]['title']) !!}</label>
@if(isset($dataLabels[1]['type']) && $dataLabels[1]['type'] == 'int')
<x-global::forms.text-input type="number" name="{{ $dataLabels[1]['field'] }}" value="{{ $canvasItem[$dataLabels[1]['field']] }}"/><br />
@elseif(isset($dataLabels[1]['type']) && $dataLabels[1]['type'] == 'string')
<x-global::forms.text-input name="{{ $dataLabels[1]['field'] }}" value="{{ $canvasItem[$dataLabels[1]['field']] }}" style="width:100%"/><br />
@else
<textarea style="width:100%" rows="3" cols="10" name="{{ $dataLabels[1]['field'] }}" class="modalTextArea tiptapSimple">{{ $canvasItem[$dataLabels[1]['field']] }}</textarea><br />
@endif
@else
<input type="hidden" name="{{ $dataLabels[1]['field'] }}" value="" />
@endif
@if($dataLabels[2]['active'])
<label>{!! __($dataLabels[2]['title']) !!}</label>
@if(isset($dataLabels[2]['type']) && $dataLabels[2]['type'] == 'int')
<x-global::forms.text-input type="number" name="{{ $dataLabels[2]['field'] }}" value="{{ $canvasItem[$dataLabels[2]['field']] }}"/><br />
@elseif(isset($dataLabels[2]['type']) && $dataLabels[2]['type'] == 'string')
<x-global::forms.text-input name="{{ $dataLabels[2]['field'] }}" value="{{ $canvasItem[$dataLabels[2]['field']] }}" style="width:100%"/><br />
@else
<textarea style="width:100%" rows="3" cols="10" name="{{ $dataLabels[2]['field'] }}" class="modalTextArea tiptapSimple">{{ $canvasItem[$dataLabels[2]['field']] }}</textarea><br />
@endif
@else
<input type="hidden" name="{{ $dataLabels[2]['field'] }}" value="" />
@endif
@if($dataLabels[3]['active'])
<label>{!! __($dataLabels[3]['title']) !!}</label>
@if(isset($dataLabels[3]['type']) && $dataLabels[3]['type'] == 'int')
<x-global::forms.text-input type="number" name="{{ $dataLabels[3]['field'] }}" value="{{ $canvasItem[$dataLabels[3]['field']] }}"/><br />
@elseif(isset($dataLabels[3]['type']) && $dataLabels[3]['type'] == 'string')
<x-global::forms.text-input name="{{ $dataLabels[3]['field'] }}" value="{{ $canvasItem[$dataLabels[3]['field']] }}"/><br />
@else
<textarea style="width:100%" rows="3" cols="10" name="{{ $dataLabels[3]['field'] }}" class="modalTextArea tiptapSimple">{{ $canvasItem[$dataLabels[3]['field']] }}</textarea><br />
@endif
@else
<input type="hidden" name="{{ $dataLabels[3]['field'] }}" value="" />
@endif
<input type="hidden" name="milestoneId" value="{{ $canvasItem['milestoneId'] }}" />
<input type="hidden" name="changeItem" value="1" />
@if($id != '')
<x-global::forms.button tag="a" link="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/delCanvasItem/{{ $id }}" class="blueprintsCanvasModal delete right" state="danger" variant="outline"><i class='fa fa-trash-can'></i> {!! __('links.delete') !!}</x-global::forms.button>
@endif
@if($login::userIsAtLeast($roles::$editor))
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" id="primaryCanvasSubmitButton" />
<x-global::forms.button inputType="submit" contentRole="secondary" value="closeModal" id="saveAndClose" onclick="leantime.blueprintsController.setCloseModal();">{!! __('buttons.save_and_close') !!}</x-global::forms.button>
@endif
@if($id !== '')
<br /><br />
<h4 class="widgettitle title-light"><span class="fa fa-link"></span> {!! __('headlines.linked_milestone') !!} <i class="fa fa-question-circle-o helperTooltip" data-tippy-content="{{ __('tooltip.link_milestones_tooltip') }}"></i></h4>
@if($canvasItem['milestoneId'] == '')
<center>
<h4>{!! __('headlines.no_milestone_link') !!}</h4>
<div class="row" id="milestoneSelectors">
@if($login::userIsAtLeast($roles::$editor))
<div class="col-md-12">
<a href="javascript:void(0);" onclick="leantime.blueprintsController.toggleMilestoneSelectors('new');">{!! __('links.create_link_milestone') !!}</a>
@if(count($milestones) > 0)
| <a href="javascript:void(0);" onclick="leantime.blueprintsController.toggleMilestoneSelectors('existing');">{!! __('links.link_existing_milestone') !!}</a>
@endif
</div>
@endif
</div>
<div class="row" id="newMilestone" style="display:none;">
<div class="col-md-12">
<x-global::forms.text-input width="50%" name="newMilestone" /><br />
<input type="hidden" name="type" value="milestone" />
<input type="hidden" name="blueprintscanvasitemid" value="{{ $id }} " />
<x-global::forms.button tag="input" inputType="button" :labelText="__('buttons.save')" onclick="jQuery('#primaryCanvasSubmitButton').click()" contentRole="primary" />
<x-global::forms.button tag="input" inputType="button" :labelText="__('buttons.cancel')" onclick="leantime.blueprintsController.toggleMilestoneSelectors('hide')" contentRole="tertiary" />
</div>
</div>
<div class="row" id="existingMilestone" style="display:none;">
<div class="col-md-12">
<select data-placeholder="{{ __('input.placeholders.filter_by_milestone') }}" name="existingMilestone" class="user-select">
<option value=""></option>
@foreach($milestones as $milestoneRow)
<option value="{{ $milestoneRow->id }}"
@if(isset($searchCriteria['milestone']) && $searchCriteria['milestone'] == $milestoneRow->id) selected='selected' @endif
>{{ $milestoneRow->headline }}</option>
@endforeach
</select>
<input type="hidden" name="type" value="milestone" />
<input type="hidden" name="blueprintscanvasitemid" value="{{ $id }} " />
<x-global::forms.button tag="input" inputType="button" :labelText="__('buttons.save')" onclick="jQuery('#primaryCanvasSubmitButton').click()" contentRole="primary" />
<x-global::forms.button tag="input" inputType="button" :labelText="__('buttons.cancel')" onclick="leantime.blueprintsController.toggleMilestoneSelectors('hide')" contentRole="tertiary" />
</div>
</div>
</center>
@else
<div hx-trigger="load"
hx-indicator=".htmx-indicator"
hx-get="{{ BASE_URL }}/hx/tickets/milestones/showCard?milestoneId={{ $canvasItem['milestoneId'] }}">
<div class="htmx-indicator">
{!! __('label.loading_milestone') !!}
</div>
</div>
<x-global::forms.button tag="a" link="{{ CURRENT_URL }}?removeMilestone={{ $canvasItem['milestoneId'] }}" class="blueprintsCanvasModal delete formModal" state="danger" variant="outline"><i class="fa fa-close"></i> {!! __('links.remove') !!}</x-global::forms.button>
@endif
@endif
</form>
@if($id !== '')
<br />
<input type="hidden" name="comment" value="1" />
<h4 class="widgettitle title-light"><span class="fa fa-comments"></span>{!! __('subtitles.discussion') !!}</h4>
@include('comments::submodules.generalComment', ['formUrl' => '/blueprints/' . $canvasSlug . '/editCanvasItem/' . $id])
@endif
</div>
<script type="text/javascript">
jQuery(document).ready(function(){
@if(! empty($statusLabels))
new SlimSelect({
select: '#statusCanvas',
showSearch: false,
valuesUseText: false,
data: [
@foreach($statusLabels as $key => $data)
@if($data['active'])
{ innerHTML: '<i class="fas fa-fw {{ $data['icon'] }}"></i>&nbsp;{{ $data['title'] }}',
text: "{{ $data['title'] }}", value: "{{ $key }}", selected: {{ $canvasItem['status'] == $key ? 'true' : 'false' }}},
@endif
@endforeach
]
});
@endif
@if(! empty($relatesLabels))
new SlimSelect({
select: '#relatesCanvas',
showSearch: false,
valuesUseText: false,
data: [
@foreach($relatesLabels as $key => $data)
@if($data['active'])
{ innerHTML: '<i class="fas fa-fw {{ $data['icon'] }}"></i>&nbsp;{{ $data['title'] }}',
text: "{{ $data['title'] }}", value: "{{ $key }}", selected: {{ $canvasItem['relates'] == $key ? 'true' : 'false' }}},
@endif
@endforeach
]
});
@endif
if (window.leantime && window.leantime.tiptapController) {
leantime.tiptapController.initSimpleEditor();
}
@if(! $login::userIsAtLeast($roles::$editor))
leantime.authController.makeInputReadonly(".nyroModalCont");
@endif
@if($login::userHasRole([$roles::$commenter]))
leantime.commentsController.enableCommenterForms();
@endif
})
</script>

View File

@@ -0,0 +1,89 @@
slug: "cp"
icon: "fa-city"
disclaimer: "text.cp.disclaimer"
minColumns: 7
boxes:
cp_cj_rv:
icon: "fa-money-bills"
title: "box.cp.cj_rv"
cp_cj_rc:
icon: "fa-hand-holding-dollar"
title: "box.cp.cj_rc"
cp_cj_e:
icon: "fa-thumbs-up"
title: "box.cp.cj_e"
cp_ou_rv:
icon: "fa-money-bills"
title: "box.cp.ou_rv"
cp_ou_rc:
icon: "fa-hand-holding-dollar"
title: "box.cp.ou_rc"
cp_ou_e:
icon: "fa-thumbs-up"
title: "box.cp.ou_e"
cp_os_rv:
icon: "fa-money-bills"
title: "box.cp.os_rv"
cp_os_rc:
icon: "fa-hand-holding-dollar"
title: "box.cp.os_rc"
cp_os_e:
icon: "fa-thumbs-up"
title: "box.cp.os_e"
cp_oi_rv:
icon: "fa-money-bills"
title: "box.cp.oi_rv"
cp_oi_rc:
icon: "fa-hand-holding-dollar"
title: "box.cp.oi_rc"
cp_oi_e:
icon: "fa-thumbs-up"
title: "box.cp.oi_e"
statusLabels: default
relatesLabels: {}
layout:
- type: header
columns:
- { width: 16, empty: true }
- { width: 84, header: { icon: "fa fa-user-doctor", title: "box.header.cp.cj" } }
- type: boxes
id: firstRow
columns:
- { width: 16, label: "box.label.cp.need" }
- { width: 28, box: "cp_cj_rv" }
- { width: 28, box: "cp_cj_rc" }
- { width: 28, box: "cp_cj_e" }
- type: separator
columns:
- { width: 16, empty: true }
- { width: 28, icon: "fa fa-arrows-up-down" }
- { width: 28, icon: "fa fa-arrows-up-down" }
- { width: 28, icon: "fa fa-arrows-up-down" }
- type: header
columns:
- { width: 16, empty: true }
- { width: 84, header: { icon: "fa fa-barcode", title: "box.header.cp.ovp" } }
- type: boxes
id: secondRow
columns:
- { width: 16, label: "box.label.cp.unique" }
- { width: 28, box: "cp_ou_rv" }
- { width: 28, box: "cp_ou_rc" }
- { width: 28, box: "cp_ou_e" }
- type: boxes
id: thirdRow
columns:
- { width: 16, label: "box.label.cp.superior" }
- { width: 28, box: "cp_os_rv" }
- { width: 28, box: "cp_os_rc" }
- { width: 28, box: "cp_os_e" }
- type: boxes
id: fourthRow
columns:
- { width: 16, label: "box.label.cp.indifferent" }
- { width: 28, box: "cp_oi_rv" }
- { width: 28, box: "cp_oi_rc" }
- { width: 28, box: "cp_oi_e" }

View File

@@ -0,0 +1,101 @@
slug: "dbm"
icon: "fa-building"
disclaimer: "text.dbm.disclaimer"
minColumns: 8
boxes:
dbm_cs:
icon: "fa-users"
color: "#ccffcc"
title: "box.dbm.cs"
dbm_cj:
icon: "fa-user-doctor"
color: "#ccffcc"
title: "box.dbm.cj"
dbm_cr:
icon: "fa-heart"
color: "#ccffcc"
title: "box.dbm.cr"
dbm_cd:
icon: "fa-truck"
color: "#ccffcc"
title: "box.dbm.cd"
dbm_ovp:
icon: "fa-money-bill-transfer"
color: "#ffcccc"
title: "box.dbm.ovp"
dbm_ops:
icon: "fa-barcode"
color: "#ffcccc"
title: "box.dbm.ops"
dbm_kad:
icon: "fa-chess"
color: "#ccecff"
title: "box.dbm.kad"
dbm_kac:
icon: "fa-hand-holding-dollar"
color: "#ccecff"
title: "box.dbm.kac"
dbm_kao:
icon: "fa-handshake"
color: "#ccecff"
title: "box.dbm.kao"
dbm_krp:
icon: "fa-apple-whole"
color: "#ccecff"
title: "box.dbm.krp"
dbm_krc:
icon: "fa-industry"
color: "#ccecff"
title: "box.dbm.krc"
dbm_krl:
icon: "fa-person-digging"
color: "#ccecff"
title: "box.dbm.krl"
dbm_krs:
icon: "fa-lightbulb"
color: "#ccecff"
title: "box.dbm.krs"
dbm_fr:
icon: "fa-sack-dollar"
color: "#ffffaa"
title: "box.dbm.fr"
dbm_fc:
icon: "fa-tags"
color: "#ffffaa"
title: "box.dbm.fc"
statusLabels: default
relatesLabels: {}
layout:
- type: boxes
id: firstRow
columns:
- { width: 20, box: "dbm_cs" }
- { width: 20, box: "dbm_cr" }
- { width: 20, box: "dbm_ovp" }
- { width: 13.33, box: "dbm_kad" }
- { width: 13.33, box: "dbm_kac" }
- { width: 13.33, box: "dbm_kao" }
- type: boxes
id: secondRow
columns:
- { width: 20, box: "dbm_cj" }
- { width: 20, box: "dbm_cd" }
- { width: 20, box: "dbm_ops" }
- { width: 40, nested: true, rows: [
{ id: "secondRowTop", columns: [
{ width: 50, box: "dbm_krp" },
{ width: 50, box: "dbm_krc" }
]},
{ id: "secondRowBottom", columns: [
{ width: 50, box: "dbm_krl" },
{ width: 50, box: "dbm_krs" }
]}
]}
- type: boxes
id: thirdRow
columns:
- { width: 50, box: "dbm_fr" }
- { width: 50, box: "dbm_fc" }

View File

@@ -0,0 +1,95 @@
slug: "ea"
icon: "fa-seedling"
disclaimer: ""
minColumns: 4
boxes:
ea_political:
icon: "fa-landmark"
title: "box.ea.political"
ea_economic:
icon: "fa-chart-line"
title: "box.ea.economic"
ea_societal:
icon: "fa-people-arrows"
title: "box.ea.societal"
ea_technological:
icon: "fa-computer"
title: "box.ea.technological"
ea_legal:
icon: "fa-scale-balanced"
title: "box.ea.legal"
ea_ecological:
icon: "fa-cloud-sun"
title: "box.ea.ecological"
statusLabels:
status_observation:
icon: "fa-tower-observation"
color: "blue"
title: "status.ea.observation"
dropdown: "info"
active: true
status_threat:
icon: "fa-cloud-bolt"
color: "red"
title: "status.ea.threat"
dropdown: "danger"
active: true
status_trend:
icon: "fa-arrow-trend-up"
color: "lightgreen"
title: "status.ea.trend"
dropdown: "success"
active: true
relatesLabels:
relates_none:
icon: "fa-border-none"
color: "grey"
title: "relates.none"
dropdown: "default"
active: true
relates_customers:
icon: "fa-users"
color: "green"
title: "relates.customers"
dropdown: "success"
active: true
relates_offerings:
icon: "fa-barcode"
color: "red"
title: "relates.offerings"
dropdown: "danger"
active: true
relates_markets:
icon: "fa-shop"
color: "brown"
title: "relates.markets"
dropdown: "default"
active: true
relates_stakeholders:
icon: "fa-handshake"
color: "orange"
title: "relates.stakeholders"
dropdown: "warning"
active: true
dataLabels:
1: { title: "label.description", field: "conclusion", active: true }
2: { title: "label.data", field: "data", active: false }
3: { title: "label.assumption", field: "assumptions", active: false }
layout:
- type: boxes
id: firstRow
columns:
- { width: 33.33, box: "ea_political" }
- { width: 33.33, box: "ea_economic" }
- { width: 33.33, box: "ea_societal" }
- type: boxes
id: secondRow
columns:
- { width: 33.33, box: "ea_technological" }
- { width: 33.33, box: "ea_legal" }
- { width: 33.33, box: "ea_ecological" }

View File

@@ -0,0 +1,73 @@
slug: "em"
icon: "fa-heart"
disclaimer: "text.em.disclaimer"
minColumns: 4
boxes:
em_who:
icon: "fa-1"
title: "box.em.who"
em_what:
icon: "fa-2"
title: "box.em.what"
em_see:
icon: "fa-3"
title: "box.em.see"
em_say:
icon: "fa-4"
title: "box.em.say"
em_do:
icon: "fa-5"
title: "box.em.do"
em_hear:
icon: "fa-6"
title: "box.em.hear"
em_pains:
icon: "fa-face-frown"
title: "box.em.pains"
em_gains:
icon: "fa-face-smile"
title: "box.em.gains"
em_motives:
icon: "fa-face-rolling-eyes"
title: "box.em.motives"
statusLabels: default
relatesLabels: {}
dataLabels:
1: { title: "label.em.description", field: "conclusion", active: true }
2: { title: "label.data", field: "data", active: false }
3: { title: "label.conclusion", field: "assumptions", active: false }
layout:
- type: header
columns:
- { width: 100, header: { icon: "fas fa-bullseye", title: "box.em.header.goal" } }
- type: boxes
id: firstRow
columns:
- { width: 50, box: "em_who" }
- { width: 50, box: "em_what" }
- type: header
columns:
- { width: 100, header: { icon: "fas fa-heart", title: "box.em.header.empathy" } }
- type: boxes
id: secondRow
columns:
- { width: 25, box: "em_see" }
- { width: 25, box: "em_say" }
- { width: 25, box: "em_do" }
- { width: 25, box: "em_hear" }
- type: header
columns:
- { width: 100, header: { icon: "fas fa-7", title: "box.em.header.think_feel" } }
- type: boxes
id: thirdRow
columns:
- { width: 50, box: "em_pains" }
- { width: 50, box: "em_gains" }
- type: boxes
id: fourthRow
columns:
- { width: 100, box: "em_motives" }

View File

@@ -0,0 +1,40 @@
slug: "insights"
icon: "fa-note-sticky"
disclaimer: ""
minColumns: 5
boxes:
insights_oberve:
icon: "fa-tower-observation"
title: "box.insights.observe"
insights_interview:
icon: "fa-people-arrows"
title: "box.insights.interview"
insights_focus_groups:
icon: "fa-people-line"
title: "box.insights.focus_groups"
insights_secondary_research:
icon: "fa-book"
title: "box.insights.secondary_research"
insights_knowledge:
icon: "fa-file-signature"
title: "box.insights.knowledge"
color: "#e3e3e3"
statusLabels: default
relatesLabels: default
dataLabels:
1: { title: "label.insights.insight", field: "conclusion", active: true }
2: { title: "label.insights.data", field: "data", active: true }
3: { title: "", field: "assumptions", active: false }
layout:
- type: boxes
id: firstRow
columns:
- { width: 20, box: "insights_oberve" }
- { width: 20, box: "insights_interview" }
- { width: 20, box: "insights_focus_groups" }
- { width: 20, box: "insights_secondary_research" }
- { width: 20, box: "insights_knowledge" }

View File

@@ -0,0 +1,37 @@
slug: "lbm"
icon: "fa-building"
disclaimer: "text.lbm.disclaimer"
minColumns: 3
boxes:
lbm_customers:
icon: "fa-users"
color: "#ccffcc"
title: "box.lbm.customers"
lbm_offerings:
icon: "fa-barcode"
color: "#ffcccc"
title: "box.lbm.offerings"
lbm_capabilities:
icon: "fa-pen-ruler"
color: "#ccecff"
title: "box.lbm.capabilities"
lbm_financials:
icon: "fa-money-bill"
color: "#ffffaa"
title: "box.lbm.financials"
statusLabels: default
relatesLabels: {}
layout:
- type: boxes
id: firstRow
columns:
- { width: 33.33, box: "lbm_customers" }
- { width: 33.33, box: "lbm_offerings" }
- { width: 33.33, box: "lbm_capabilities" }
- type: boxes
id: secondRow
columns:
- { width: 100, box: "lbm_financials" }

View File

@@ -0,0 +1,68 @@
slug: "lean"
icon: "fa-flask"
disclaimer: "text.lean.disclaimer"
minColumns: 5
boxes:
problem:
icon: "fa-lock"
title: "box.lean.problem"
alternatives:
icon: "fa-arrow-down-up-across-line"
title: "box.lean.alternatives"
solution:
icon: "fa-key"
title: "box.lean.solution"
keymetrics:
icon: "fa-chart-column"
title: "box.lean.keymetrics"
uniquevalue:
icon: "fa-gift"
title: "box.lean.uniquevalue"
highlevelconcept:
icon: "fa-wand-magic-sparkles"
title: "box.lean.highlevelconcept"
unfairadvantage:
icon: "fa-person-running"
title: "box.lean.unfairadvantage"
channels:
icon: "fa-truck"
title: "box.lean.channels"
customersegment:
icon: "fa-user"
title: "box.lean.customersegment"
earlyadopters:
icon: "fa-chart-pie"
title: "box.lean.earlyadopters"
cost:
icon: "fa-file-invoice-dollar"
title: "box.lean.cost"
revenue:
icon: "fa-sack-dollar"
title: "box.lean.revenue"
statusLabels: default
relatesLabels: {}
layout:
- type: boxes
id: firstRow
columns:
- { width: 20, box: "problem" }
- { width: 20, box: "solution" }
- { width: 20, box: "uniquevalue" }
- { width: 20, box: "unfairadvantage" }
- { width: 20, box: "customersegment" }
- type: boxes
id: secondRow
columns:
- { width: 20, box: "alternatives" }
- { width: 20, box: "keymetrics" }
- { width: 20, box: "highlevelconcept" }
- { width: 20, box: "channels" }
- { width: 20, box: "earlyadopters" }
- type: boxes
id: thirdRow
columns:
- { width: 50, box: "cost" }
- { width: 50, box: "revenue" }

View File

@@ -0,0 +1,47 @@
slug: "minempathy"
icon: "fa-solid fa-heart-circle-check"
disclaimer: ""
minColumns: 2
boxes:
minempathy_who:
icon: ""
title: "box.minempathy.who"
minempathy_struggles:
icon: ""
title: "box.minempathy.struggles"
minempathy_where:
icon: ""
title: "box.minempathy.where"
minempathy_why:
icon: ""
title: "box.minempathy.why"
minempathy_how:
icon: ""
title: "box.minempathy.how"
statusLabels: default
relatesLabels: {}
dataLabels:
1: { title: "label.description", field: "conclusion", active: true }
2: { title: "label.data", field: "data", active: true }
3: { title: "label.assumptions", field: "assumptions", active: true }
layout:
- type: boxes
id: firstRow
columns:
- { width: 50, box: "minempathy_who" }
- { width: 50, box: "minempathy_struggles" }
- type: boxes
id: secondRow
columns:
- { width: 25, empty: true }
- { width: 50, box: "minempathy_where" }
- { width: 25, empty: true }
- type: boxes
id: thirdRow
columns:
- { width: 50, box: "minempathy_why" }
- { width: 50, box: "minempathy_how" }

View File

@@ -0,0 +1,58 @@
slug: "obm"
icon: "fa-object-group"
disclaimer: "text.obm.disclaimer"
minColumns: 5
minWidthOffset: 50
boxes:
obm_kp:
icon: "fa-ring"
title: "box.obm.kp"
obm_kr:
icon: "fa-hammer"
title: "box.obm.kr"
obm_ka:
icon: "fa-person-digging"
title: "box.obm.ka"
obm_vp:
icon: "fa-gift"
title: "box.obm.vp"
obm_ch:
icon: "fa-truck"
title: "box.obm.ch"
obm_cr:
icon: "fa-heart"
title: "box.obm.cr"
obm_cs:
icon: "fa-person"
title: "box.obm.cs"
obm_fc:
icon: "fa-file-invoice-dollar"
title: "box.obm.fc"
obm_fr:
icon: "fa-cash-register"
title: "box.obm.fr"
statusLabels: default
relatesLabels: {}
layout:
- type: boxes
id: firstRow
columns:
- { width: 20, box: "obm_kp" }
- { width: 20, nested: true, rows: [
{ id: "firstRowTop", columns: [{ width: 100, box: "obm_ka" }] },
{ id: "firstRowBottom", columns: [{ width: 100, box: "obm_kr" }] }
]}
- { width: 20, box: "obm_vp" }
- { width: 20, nested: true, rows: [
{ id: "firstRowTop2", columns: [{ width: 100, box: "obm_cr" }] },
{ id: "firstRowBottom2", columns: [{ width: 100, box: "obm_ch" }] }
]}
- { width: 20, box: "obm_cs" }
- type: boxes
id: secondRow
columns:
- { width: 50, box: "obm_fc" }
- { width: 50, box: "obm_fr" }

View File

@@ -0,0 +1,31 @@
slug: "retros"
icon: "fa-hand-spock"
disclaimer: ""
minColumns: 3
boxes:
well:
icon: "fa-circle-check"
title: "box.retros.continue"
notwell:
icon: "fa-circle-xmark"
title: "box.retros.stop_doing"
startdoing:
icon: "fa-circle-plus"
title: "box.retros.start_doing"
statusLabels: {}
relatesLabels: {}
dataLabels:
1: { title: "label.description", field: "conclusion", active: true }
2: { title: "label.data", field: "data", active: false }
3: { title: "label.assumptions", field: "assumptions", active: false }
layout:
- type: boxes
id: firstRow
columns:
- { width: 33, box: "well" }
- { width: 33, box: "notwell" }
- { width: 33, box: "startdoing" }

View File

@@ -0,0 +1,38 @@
slug: "risks"
icon: "fa-person-falling"
disclaimer: ""
minColumns: 2
boxes:
risks_imp_low_pro_low:
icon: ""
title: "box.risks.imp_low_pro_low"
risks_imp_low_pro_high:
icon: ""
title: "box.risks.imp_low_pro_high"
risks_imp_high_pro_low:
icon: ""
title: "box.risks.imp_high_pro_low"
risks_imp_high_pro_high:
icon: ""
title: "box.risks.imp_high_pro_high"
statusLabels: default
relatesLabels: default
dataLabels:
1: { title: "label.risks.description", field: "conclusion", active: true }
2: { title: "label.data", field: "data", active: true }
3: { title: "label.risks.mitigation", field: "assumptions", active: true }
layout:
- type: boxes
id: firstRow
columns:
- { width: 50, box: "risks_imp_low_pro_high" }
- { width: 50, box: "risks_imp_high_pro_high" }
- type: boxes
id: secondRow
columns:
- { width: 50, box: "risks_imp_low_pro_low" }
- { width: 50, box: "risks_imp_high_pro_low" }

View File

@@ -0,0 +1,97 @@
slug: "sb"
icon: "fa-briefcase"
disclaimer: ""
minColumns: 4
boxes:
sb_industry:
icon: "fa-industry"
title: "box.sb.industry"
sb_description:
icon: "fa-file-lines"
title: "box.sb.description"
sb_st_design:
icon: "fa-user-tie"
title: "box.sb.st_design"
sb_st_decision:
icon: "fa-sitemap"
title: "box.sb.st_decision"
sb_st_experts:
icon: "fa-chalkboard-user"
title: "box.sb.st_experts"
sb_st_support:
icon: "fa-person-circle-question"
title: "box.sb.st_support"
sb_budget:
icon: "fa-money-bills"
title: "box.sb.budget"
sb_time:
icon: "fa-business-time"
title: "box.sb.time"
sb_culture:
icon: "fa-masks-theater"
title: "box.sb.culture"
sb_change:
icon: "fa-book-skull"
title: "box.sb.change"
sb_principles:
icon: "fa-ruler-combined"
title: "box.sb.principles"
statusLabels:
status_pending:
icon: "fa-person-circle-question"
color: "blue"
title: "status.pending"
dropdown: "info"
active: true
status_accepted:
icon: "fa-person-circle-check"
color: "green"
title: "status.accepted"
dropdown: "success"
active: true
status_rejected:
icon: "fa-person-circle-xmark"
color: "red"
title: "status.rejected"
dropdown: "danger"
active: true
relatesLabels: {}
dataLabels:
1: { title: "label.description", field: "conclusion", active: true }
2: { title: "label.data", field: "data", active: false }
3: { title: "label.assumptions", field: "assumptions", active: false }
layout:
- type: boxes
columns:
- { width: 100, box: "sb_description", statusLabels: {} }
- type: boxes
columns:
- { width: 100, box: "sb_industry", statusLabels: {} }
- type: boxes
id: stakeholderRow
columns:
- { width: 25, box: "sb_st_design", statusLabels: "inherit" }
- { width: 25, box: "sb_st_decision", statusLabels: "inherit" }
- { width: 25, box: "sb_st_experts", statusLabels: "inherit" }
- { width: 25, box: "sb_st_support", statusLabels: "inherit" }
- type: boxes
id: financialsRow
columns:
- { width: 50, box: "sb_budget", statusLabels: {} }
- { width: 50, box: "sb_time", statusLabels: {} }
- type: boxes
id: culturechangeRow
columns:
- { width: 50, box: "sb_culture", statusLabels: {} }
- { width: 50, box: "sb_change", statusLabels: {} }
- type: boxes
columns:
- { width: 100, box: "sb_principles", statusLabels: {} }
- type: static
columns:
- { width: 100, icon: "fas fa-person-falling", title: "box.sb.risks", content: "text.sb.risks_analysis" }

View File

@@ -0,0 +1,83 @@
slug: "sm"
icon: "fa-chess"
disclaimer: ""
minColumns: 2
boxes:
sm_qa:
icon: "fa-clipboard-question"
title: "box.sm.qa"
sm_qb:
icon: "fa-clipboard-question"
title: "box.sm.qb"
sm_qc:
icon: "fa-clipboard-question"
title: "box.sm.qc"
sm_qd:
icon: "fa-clipboard-question"
title: "box.sm.qd"
sm_qe:
icon: "fa-clipboard-question"
title: "box.sm.qe"
sm_qf:
icon: "fa-clipboard-question"
title: "box.sm.qf"
sm_qg:
icon: "fa-clipboard-question"
title: "box.sm.qg"
statusLabels:
status_draft:
icon: "fa-circle-question"
color: "blue"
title: "status.draft"
dropdown: "info"
active: true
status_review:
icon: "fa-circle-exclamation"
color: "orange"
title: "status.review"
dropdown: "warning"
active: true
status_accepted:
icon: "fa-circle-check"
color: "green"
title: "status.accepted"
dropdown: "success"
active: true
status_rejected:
icon: "fa-circle-xmark"
color: "red"
title: "status.rejected"
dropdown: "danger"
active: true
relatesLabels: {}
dataLabels:
1: { title: "label.sm.description", field: "conclusion", active: true }
2: { title: "label.data", field: "data", active: false }
3: { title: "label.assumptions", field: "assumptions", active: false }
layout:
- type: boxes
columns:
- { width: 100, box: "sm_qa" }
- type: boxes
columns:
- { width: 100, box: "sm_qb" }
- type: boxes
columns:
- { width: 100, box: "sm_qc" }
- type: boxes
columns:
- { width: 100, box: "sm_qd" }
- type: boxes
columns:
- { width: 100, box: "sm_qe" }
- type: boxes
columns:
- { width: 100, box: "sm_qf" }
- type: boxes
columns:
- { width: 100, box: "sm_qg" }

View File

@@ -0,0 +1,71 @@
slug: "sq"
icon: "fa-chess"
disclaimer: ""
minColumns: 2
boxes:
sq_qa:
icon: "fa-1"
title: "box.sq.qa"
sq_qb:
icon: "fa-2"
title: "box.sq.qb"
sq_qc:
icon: "fa-3"
title: "box.sq.qc"
sq_qd:
icon: "fa-4"
title: "box.sq.qd"
sq_qe:
icon: "fa-5"
title: "box.sq.qe"
statusLabels:
status_draft:
icon: "fa-circle-question"
color: "blue"
title: "status.draft"
dropdown: "info"
active: true
status_review:
icon: "fa-circle-exclamation"
color: "orange"
title: "status.review"
dropdown: "warning"
active: true
status_accepted:
icon: "fa-circle-check"
color: "green"
title: "status.accepted"
dropdown: "success"
active: true
status_rejected:
icon: "fa-circle-xmark"
color: "red"
title: "status.rejected"
dropdown: "danger"
active: true
relatesLabels: {}
dataLabels:
1: { title: "label.sq.description", field: "conclusion", active: true }
2: { title: "label.data", field: "data", active: false }
3: { title: "label.assumptions", field: "assumptions", active: false }
layout:
- type: boxes
columns:
- { width: 100, box: "sq_qa" }
- type: boxes
columns:
- { width: 100, box: "sq_qb" }
- type: boxes
columns:
- { width: 100, box: "sq_qc" }
- type: boxes
columns:
- { width: 100, box: "sq_qd" }
- type: boxes
columns:
- { width: 100, box: "sq_qe" }

View File

@@ -0,0 +1,42 @@
slug: "swot"
icon: "fa-chess-board"
disclaimer: ""
minColumns: 2
boxes:
swot_strengths:
icon: "fa-dumbbell"
title: "box.swot.strengths"
swot_weaknesses:
icon: "fa-fire"
title: "box.swot.weaknesses"
swot_opportunities:
icon: "fa-clover"
title: "box.swot.opportunities"
swot_threats:
icon: "fa-bolt-lightning"
title: "box.swot.threats"
statusLabels: {}
relatesLabels: default
dataLabels:
1: { title: "label.description", field: "conclusion", active: true }
2: { title: "label.data", field: "data", active: true }
3: { title: "label.assumptions", field: "assumptions", active: false }
layout:
- type: header
columns:
- { width: 50, header: { icon: "far fa-thumbs-up", title: "box.header.swot.helpful" } }
- { width: 50, header: { icon: "far fa-thumbs-down", title: "box.header.swot.harmful" } }
- type: boxes
id: firstRow
columns:
- { width: 50, box: "swot_strengths" }
- { width: 50, box: "swot_weaknesses" }
- type: boxes
id: secondRow
columns:
- { width: 50, box: "swot_opportunities" }
- { width: 50, box: "swot_threats" }

View File

@@ -0,0 +1,35 @@
slug: "value"
icon: "fa-ranking-star"
disclaimer: ""
minColumns: 5
boxes:
customersegment:
icon: "fa-user"
title: "box.lean.customersegment"
problem:
icon: "fa-lock"
title: "box.lean.problem"
solution:
icon: "fa-key"
title: "box.lean.solution"
uniquevalue:
icon: "fa-gift"
title: "box.value.benefit"
statusLabels: default
relatesLabels: {}
dataLabels:
1: { title: "label.valueCanvas.assumptions", field: "assumptions", active: true }
2: { title: "label.valueCanvas.data", field: "data", active: true }
3: { title: "label.valueCanvas.conclusion", field: "conclusion", active: true }
layout:
- type: boxes
id: firstRow
columns:
- { width: 25, box: "customersegment" }
- { width: 25, box: "problem" }
- { width: 25, box: "solution" }
- { width: 25, box: "uniquevalue" }

View File

@@ -0,0 +1,8 @@
<h4 class="widgettitle title-light">{!! __('subtitles.delete') !!}</h4>
<form method="post" action="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/delCanvas/{{ $id }}">
<p>{!! __('text.confirm_board_deletion') !!}</p><br />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.yes_delete')" name="del" />
<x-global::forms.button tag="a" contentRole="tertiary"
link="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/showCanvas">{!! __('buttons.back') !!}</x-global::forms.button>
</form>

View File

@@ -0,0 +1,8 @@
<h4 class="widgettitle title-light">{!! __('subtitles.delete') !!}</h4>
<hr style="margin-top: 5px; margin-bottom: 15px;">
<form method="post" action="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/delCanvasItem/{{ $id }}">
<p>{!! __('text.confirm_board_item_deletion') !!}</p><br />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.yes_delete')" name="del" />
<x-global::forms.button tag="a" contentRole="tertiary" link="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/showCanvas">{!! __('buttons.back') !!}</x-global::forms.button>
</form>

View File

@@ -0,0 +1,147 @@
<h4 class="widgettitle title-primary">
@if(isset($canvasTypes[$elementName]['icon']))
<i class="fas {{ $canvasTypes[$elementName]['icon'] }}"></i>
@endif
{{ $canvasTypes[$elementName]['title'] }}
</h4>
<div class="contentInner even status_{{ $elementName }}"
{!! isset($canvasTypes[$elementName]['color']) ? 'style="background: ' . $canvasTypes[$elementName]['color'] . ';"' : '' !!}>
@foreach($canvasItems as $row)
@php
$filterStatus = $filter['status'] ?? 'all';
$filterRelates = $filter['relates'] ?? 'all';
@endphp
@if($row['box'] === $elementName && ($filterStatus == 'all' || $filterStatus == $row['status']) && ($filterRelates == 'all' || $filterRelates == $row['relates']))
@php
// Use the module-scoped count already computed by getCanvasItemsById
// (avoids an unscoped per-item query that miscounts across modules).
$nbcomments = (int) ($row['commentCount'] ?? 0);
@endphp
<div class="ticketBox" id="item_{{ $row['id'] }}">
<div class="row">
<div class="col-md-12">
<div class="inlineDropDownContainer" style="float:right;">
@if($login::userIsAtLeast($roles::$editor))
<a href="javascript:void(0)" class="dropdown-toggle ticketDropDown" data-toggle="dropdown">
<i class="fa fa-ellipsis-v" aria-hidden="true"></i>
</a>
@endif
@if($login::userIsAtLeast($roles::$editor))
&nbsp;&nbsp;&nbsp;
<ul class="dropdown-menu">
<li class="nav-header">{!! __('subtitles.edit') !!}</li>
<li><a href="#/blueprints/{{ $canvasSlug }}/editCanvasItem/{{ $row['id'] }}"
data="item_{{ $row['id'] }}"> {!! __('links.edit_canvas_item') !!}</a></li>
<li><a href="#/blueprints/{{ $canvasSlug }}/delCanvasItem/{{ $row['id'] }}"
class="delete"
data="item_{{ $row['id'] }}"> {!! __('links.delete_canvas_item') !!}</a></li>
</ul>
@endif
</div>
<h4><a href="#/blueprints/{{ $canvasSlug }}/editCanvasItem/{{ $row['id'] }}"
data="item_{{ $row['id'] }}">{{ $row['description'] }}</a></h4>
@if($row['conclusion'] != '')
<small>{!! $tpl->convertRelativePaths($row['conclusion']) !!}</small>
@endif
<div class="clearfix" style="padding-bottom: 8px;"></div>
@if(! empty($statusLabels))
<div class="dropdown ticketDropdown statusDropdown colorized show firstDropdown">
<a class="dropdown-toggle f-left status label-{{ $statusLabels[$row['status']]['dropdown'] }}"
href="javascript:void(0);" role="button"
id="statusDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">{{ $statusLabels[$row['status']]['title'] }}</span> <i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="statusDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{!! __('dropdown.choose_status') !!}</li>
@foreach($statusLabels as $key => $data)
@if($data['active'] || true)
<li class='dropdown-item'>
<a href="javascript:void(0);" class="label-{{ $data['dropdown'] }}"
data-label='{{ $data['title'] }}' data-value="{{ $row['id'] . '/' . $key }}"
id="ticketStatusChange{{ $row['id'] }}{{ $key }}">{{ $data['title'] }}</a>
</li>
@endif
@endforeach
</ul>
</div>
@endif
@if(! empty($relatesLabels))
<div class="dropdown ticketDropdown relatesDropdown colorized show firstDropdown">
<a class="dropdown-toggle f-left relates label-{{ $relatesLabels[$row['relates']]['dropdown'] }}"
href="javascript:void(0);" role="button"
id="relatesDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true"
aria-expanded="false">
<span class="text">{{ $relatesLabels[$row['relates']]['title'] }}</span> <i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="relatesDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{!! __('dropdown.choose_relates') !!}</li>
@foreach($relatesLabels as $key => $data)
@if($data['active'] || true)
<li class='dropdown-item'>
<a href="javascript:void(0);" class="label-{{ $data['dropdown'] }}"
data-label='{{ $data['title'] }}'
data-value="{{ $row['id'] . '/' . $key }}"
id="ticketRelatesChange{{ $row['id'] }}{{ $key }}">{{ $data['title'] }}</a>
</li>
@endif
@endforeach
</ul>
</div>
@endif
<div class="dropdown ticketDropdown userDropdown noBg show right lastDropdown dropRight">
<a class="dropdown-toggle f-left" href="javascript:void(0);" role="button" id="userDropdownMenuLink{{ $row['id'] }}"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">
@if($row['authorFirstname'] != '')
<span id='userImage{{ $row['id'] }}'><img src='{{ BASE_URL }}/api/users?profileImage={{ $row['author'] }}' width='25' style='vertical-align: middle;'/></span><span id='user{{ $row['id'] }}'></span>
@else
<span id='userImage{{ $row['id'] }}'><img src='{{ BASE_URL }}/api/users?profileImage=false' width='25' style='vertical-align: middle;'/></span><span id='user{{ $row['id'] }}'></span>
@endif
</span>
</a>
<ul class="dropdown-menu" aria-labelledby="userDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{!! __('dropdown.choose_user') !!}</li>
@foreach($users as $user)
<li class='dropdown-item'>
<a href='javascript:void(0);' data-label='{{ sprintf(__('text.full_name'), e($user['firstname']), e($user['lastname'])) }}' data-value='{{ $row['id'] }}_{{ $user['id'] }}_{{ $user['profileId'] }}' id='userStatusChange{{ $row['id'] }}{{ $user['id'] }}'><img src='{{ BASE_URL }}/api/users?profileImage={{ $user['id'] }}&v={{ $user['modified'] }}' width='25' style='vertical-align: middle; margin-right:5px;'/>{{ sprintf(__('text.full_name'), e($user['firstname']), e($user['lastname'])) }}</a>
</li>
@endforeach
</ul>
</div>
<div class="pull-right" style="margin-right:10px;">
<span class="fas fa-comments"></span> <small>{{ $nbcomments }}</small>
</div>
</div>
</div>
@if($row['milestoneHeadline'] != '')
<br/>
<div hx-trigger="load"
hx-indicator=".htmx-indicator"
hx-get="{{ BASE_URL }}/hx/tickets/milestones/showCard?milestoneId={{ $row['milestoneId'] }}">
<div class="htmx-indicator">
{!! __('label.loading_milestone') !!}
</div>
</div>
@endif
</div>
@endif
@endforeach
<br />
@if($login::userIsAtLeast($roles::$editor))
<a href="#/blueprints/{{ $canvasSlug }}/editCanvasItem?type={{ $elementName }}"
class="" id="{{ $elementName }}"
style="padding-bottom: 10px;">{!! __('links.add_new_canvas_item') !!}</a>
@endif
</div>

View File

@@ -0,0 +1,22 @@
<div class="center padding-lg">
<div class="row">
<div class="col-md-12">
<div style='width:300px' class='svgContainer'>
{!! file_get_contents(ROOT . '/dist/images/svg/undraw_design_data_khdb.svg') !!}
</div>
<br />
<h1>{!! __("headlines.$canvasSlug.welcome_to_board") !!}</h1><br />
{!! __("text.$canvasSlug.helper_content") !!}
<br /><br />
</div>
</div>
<div class="row">
<div class="col-md-12">
<p></p>
<x-global::forms.button tag="a" link="javascript:void(0);" onclick="jQuery.nmTop().close()" contentRole="tertiary">{!! __('links.close') !!}</x-global::forms.button><br />
</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
{{-- modals.blade.php - Generic template for blueprints canvas modals --}}
{{-- Note: The modal content was previously commented out in the legacy template. --}}
{{-- Kept as an empty partial for compatibility. The actual board dialog is boardDialog.blade.php --}}

View File

@@ -0,0 +1,4 @@
<h4 class="widgettitle title-primary center canvas-element-title-empty">&nbsp;</h4>
<div class="contentInner even canvas-element-center-middle">
<strong>{!! __($label) !!}</strong>
</div>

View File

@@ -0,0 +1,11 @@
<div class="row canvas-row">
@foreach($row['columns'] as $col)
<div class="column" style="width: {{ $col['width'] }}%">
@if(isset($col['header']))
<h4 class="widgettitle title-primary center canvas-title-only">
<large><i class="{{ $col['header']['icon'] }}"></i> {!! __($col['header']['title']) !!}</large>
</h4>
@endif
</div>
@endforeach
</div>

View File

@@ -0,0 +1,11 @@
<div class="row canvas-row">
@foreach($row['columns'] as $col)
<div class="column center" style="width: {{ $col['width'] }}%">
@if(isset($col['icon']))
<i class="{{ $col['icon'] }}"></i>
@else
&nbsp;
@endif
</div>
@endforeach
</div>

View File

@@ -0,0 +1,14 @@
<div class="row canvas-row">
@foreach($row['columns'] as $col)
<div class="column" style="width: {{ $col['width'] }}%">
@if(isset($col['title']))
<h4 class="widgettitle title-primary center"><i class="{{ $col['icon'] ?? '' }}"></i> {!! __($col['title']) !!}</h4>
@endif
@if(isset($col['content']))
<div class="contentInner even" style="padding-top: 10px;">
{!! sprintf(__($col['content']), BASE_URL) !!}
</div>
@endif
</div>
@endforeach
</div>

View File

@@ -0,0 +1,136 @@
@extends($layout)
@section('content')
<div class="pageheader">
<div class="pageicon"><span class="fa-solid fa-chess"></span></div>
<div class="pagetitle">
<h1>{!! __('headlines.blueprints') !!}</h1>
</div>
</div>
{!! $tpl->displayNotification() !!}
<div class="maincontent">
<div class="row">
<div class="col-md-12">
<div class="maincontentinner">
<h5 class="subtitle">Jump right back in</h5>
<div class="row">
@foreach ($recentProgressCanvas as $canvasType => $board)
<div class="col-md-3">
<div class="profileBox">
<div class="commentImage icon">
<i class="{{ $board['icon'] }}"></i>
</div>
<span class="userName">
<small>{!! __($board['name']) !!} ({{ $board['count'] }})</small><br />
<a href="{{ BASE_URL }}/{{ $board['module'] }}/showCanvas/{{ $board['lastCanvasId'] }}">
{{ $tpl->escape($board['lastTitle']) }}
</a><br />
<small>{!! __('label.last_updated') !!} {{ format($board['lastUpdate'])->date() }} {{ format($board['lastUpdate'])->time() }}</p>
</small>
</span>
<div class="clearall"></div>
@php
$percentDone = 0;
if (isset($canvasProgress[$canvasType])) {
$percentDone = round($canvasProgress[$canvasType] * 100);
}
@endphp
<br />
<div class="progress">
<div class="progress-bar progress-bar-success" role="progressbar" aria-valuenow="{{ $percentDone }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ $percentDone }}%">
<span class="sr-only">{!! sprintf(__('text.percent_complete'), $percentDone) !!}</span>
</div>
</div>
{!! sprintf(__('text.percent_complete'), $percentDone) !!}
</div>
</div>
@endforeach
@if (! is_array($recentProgressCanvas) || count($recentProgressCanvas) == 0)
<div class='col-md-12'><br /><br /><div class='center'>
<div style='width:30%' class='svgContainer'>
{!! file_get_contents(ROOT . '/dist/images/svg/undraw_design_data_khdb.svg') !!}
</div>
<h3>{!! __('headline.no_blueprints_yet') !!}</h3>
<br />{!! __('text.no_blueprints_yet') !!}
<br /><x-global::forms.button tag="a" link="{{ BASE_URL }}/blueprints/value/showCanvas" contentRole="primary">{!! __('button.start_here_project_value') !!}</x-global::forms.button>
</div></div>
@endif
</div>
</div>
</div>
</div>
@if ($login::userIsAtLeast($roles::$editor))
<div class="row">
<div class="col-md-12">
<div class="maincontentinner">
<h5 class="accordionTitle" id="accordion_link_other">
<a href="javascript:void(0)" class="accordion-toggle" id="accordion_toggle_other" onclick="accordionToggle('other');">
<i class="fa fa-angle-down"></i> Templates
</a>
</h5>
<p style="padding-left:19px;">{!! __('description.other_tools') !!}</p>
<div id="accordion_other" class="row teamBox" style="padding-left:19px;">
@foreach ($otherBoards as $board)
@if (! isset($board['visible']) || $board['visible'] === 1)
<div class="col-md-3">
<div class="profileBox" style="min-height: 125px;">
<div class="commentImage icon">
<i class="{{ $board['icon'] }}"></i>
</div>
<span class="userName">
<a href="{{ BASE_URL }}/{{ $board['module'] }}/showCanvas">
{!! __($board['name']) !!}
</a>
</span>
{!! __($board['description']) !!}
<div class="clearall"></div>
</div>
</div>
@endif
@endforeach
</div>
</div>
</div>
</div>
@endif
</div>
@once
@push('scripts')
<script>
function accordionToggle(id) {
let currentLink = jQuery("#accordion_toggle_"+id).find("i.fa");
if(currentLink.hasClass("fa-angle-right")){
currentLink.removeClass("fa-angle-right");
currentLink.addClass("fa-angle-down");
jQuery('#accordion_'+id).slideDown("fast");
}else{
currentLink.removeClass("fa-angle-down");
currentLink.addClass("fa-angle-right");
jQuery('#accordion_'+id).slideUp("fast");
}
}
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,80 @@
@extends($layout)
@section('content')
@include('blueprints::showCanvasTop', ['canvasSlug' => $canvasSlug])
@if(count($allCanvas) > 0)
<div id="sortableCanvasKanban" class="sortableTicketList disabled">
<div class="row-fluid">
<div class="column" style="width: 100%; min-width: calc({{ $template->minColumns }} * 250px{{ $template->minWidthOffset ? ' + ' . $template->minWidthOffset . 'px' : '' }});">
@foreach($template->layout as $row)
@if($row['type'] === 'header')
@include('blueprints::partials.sectionHeader', ['row' => $row])
@elseif($row['type'] === 'separator')
@include('blueprints::partials.separator', ['row' => $row])
@elseif($row['type'] === 'static')
@include('blueprints::partials.staticContent', ['row' => $row])
@elseif($row['type'] === 'boxes')
<div class="row canvas-row" @if(isset($row['id'])) id="{{ $row['id'] }}" @endif>
@foreach($row['columns'] as $col)
@if(isset($col['empty']) && $col['empty'] === true)
{{-- Spacer cell: plain div without .column so it does not pick up column padding/min-width --}}
<div style="width: {{ $col['width'] }}%">&nbsp;</div>
@else
<div class="column" style="width: {{ $col['width'] }}%">
@if(isset($col['box']))
@php
// Handle per-box statusLabels overrides
$boxStatusLabels = $statusLabels;
if (array_key_exists('statusLabels', $col)) {
if ($col['statusLabels'] === 'inherit') {
$boxStatusLabels = $statusLabels;
} elseif (is_array($col['statusLabels'])) {
$boxStatusLabels = $col['statusLabels'];
} else {
$boxStatusLabels = [];
}
}
@endphp
@include('blueprints::element', [
'canvasSlug' => $canvasSlug,
'elementName' => $col['box'],
'statusLabels' => $boxStatusLabels,
'relatesLabels' => $relatesLabels,
])
@elseif(isset($col['label']))
@include('blueprints::partials.rowLabel', ['label' => $col['label']])
@elseif(isset($col['nested']) && $col['nested'] === true && isset($col['rows']))
{{-- Nested sub-grid (used by DBM and OBM canvases) --}}
@foreach($col['rows'] as $subRow)
<div class="row canvas-row" @if(isset($subRow['id'])) id="{{ $subRow['id'] }}" @endif>
@foreach($subRow['columns'] as $subCol)
<div class="column" style="width: {{ $subCol['width'] }}%">
@if(isset($subCol['box']))
@include('blueprints::element', [
'canvasSlug' => $canvasSlug,
'elementName' => $subCol['box'],
'statusLabels' => $statusLabels,
'relatesLabels' => $relatesLabels,
])
@endif
</div>
@endforeach
</div>
@endforeach
@endif
</div>
@endif
@endforeach
</div>
@endif
@endforeach
</div>
</div>
</div>
<div class="clearfix"></div>
@endif
@include('blueprints::showCanvasBottom', ['canvasSlug' => $canvasSlug])
@endsection

View File

@@ -0,0 +1,70 @@
@if(count($allCanvas) > 0)
@else
<br /><br />
<div class='center'>
<div class='svgContainer'>
{!! file_get_contents(ROOT . '/dist/images/svg/undraw_design_data_khdb.svg') !!}
</div>
<h3>{!! __("headlines.$canvasSlug.analysis") !!}</h3>
<br />{!! __("text.$canvasSlug.helper_content") !!}
@if($login::userIsAtLeast($roles::$editor))
<br /><br />
<a href='javascript:void(0)' class='addCanvasLink btn btn-primary'>
{!! __('links.icon.create_new_board') !!}</a>.
@endif
</div>
@endif
@if(! empty($disclaimer) && count($allCanvas) > 0)
<small class="align-center">{{ $disclaimer }}</small>
@endif
@include('blueprints::modals')
</div>
</div>
@once @push('scripts')
<script type="text/javascript">
jQuery(document).ready(function() {
if(jQuery('#searchCanvas').length > 0) {
new SlimSelect({ select: '#searchCanvas' });
}
@if(isset($_GET['closeModal']))
jQuery.nmTop().close();
@endif
leantime.blueprintsController.setRowHeights();
leantime.blueprintsController.setCanvasName('{{ $canvasSlug }}');
leantime.blueprintsController.initFilterBar();
@if($login::userIsAtLeast($roles::$editor))
leantime.blueprintsController.initCanvasLinks();
leantime.blueprintsController.initUserDropdown();
leantime.blueprintsController.initStatusDropdown();
leantime.blueprintsController.initRelatesDropdown();
@else
leantime.authController.makeInputReadonly(".maincontentinner");
@endif
@if(isset($_GET['showModal']))
@php
if ($_GET['showModal'] == '') {
$modalUrl = '&type=' . array_key_first($canvasTypes);
} else {
$modalUrl = '/' . (int) $_GET['showModal'];
}
@endphp
leantime.blueprintsController.openModalManually("{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/editCanvasItem{{ $modalUrl }}");
window.history.pushState({},document.title, '{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/showCanvas/');
@endif
});
</script>
@endpush @endonce

View File

@@ -0,0 +1,131 @@
@php
$canvasTitle = '';
$allCanvas = $allCanvas ?? [];
$canvasIcon = $canvasIcon ?? '';
$canvasTypes = $canvasTypes ?? [];
$statusLabels = $statusLabels ?? [];
$relatesLabels = $relatesLabels ?? [];
$dataLabels = $dataLabels ?? [];
$disclaimer = $disclaimer ?? '';
$canvasItems = $canvasItems ?? [];
$filter['status'] = $_GET['filter_status'] ?? (session('filter_status') ?? 'all');
session(['filter_status' => $filter['status']]);
$filter['relates'] = $_GET['filter_relates'] ?? (session('filter_relates') ?? 'all');
session(['filter_relates' => $filter['relates']]);
// get canvas title
foreach ($allCanvas as $canvasRow) {
if ($canvasRow['id'] == ($currentCanvas ?? '')) {
$canvasTitle = $canvasRow['title'];
break;
}
}
@endphp
<style>
.canvas-row { margin-left: 0px; margin-right: 0px;}
.canvas-title-only { border-radius: var(--box-radius-small); }
h4.canvas-element-title-empty { background: white !important; border-color: white !important; }
div.canvas-element-center-middle { text-align: center; }
</style>
<div class="pageheader">
<div class="pageicon"><span class='fa {{ $canvasIcon }}'></span></div>
<div class="pagetitle">
@if(count($allCanvas) > 0)
<x-global::subjectSwitcher
:parent="__('headline.' . $canvasSlug . '.board')"
:current="$canvasTitle">
@if($login::userIsAtLeast($roles::$editor))
<li><a href="#/blueprints/{{ $canvasSlug }}/boardDialog">{!! __('links.icon.create_new_board') !!}</a></li>
@endif
<li class="border"></li>
@foreach($allCanvas as $canvasRow)
<li><a href="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/showCanvas/{{ $canvasRow['id'] }}">{{ e($canvasRow['title']) }}</a></li>
@endforeach
</x-global::subjectSwitcher>
@else
<h1>{!! __("headline.$canvasSlug.board") !!}</h1>
@endif
</div>
@if(count($allCanvas) > 0)
<div class="pageheader-right">
<span class="dropdown dropdownWrapper headerEditDropdown">
<a href="javascript:void(0)" class="dropdown-toggle btn btn-transparent" data-toggle="dropdown"><i class="fa-solid fa-ellipsis-v"></i></a>
<ul class="dropdown-menu editCanvasDropdown">
@if($login::userIsAtLeast($roles::$editor))
<li><a href="#/blueprints/{{ $canvasSlug }}/boardDialog/{{ $currentCanvas }}" class="editCanvasLink ">{!! __('links.icon.edit') !!}</a></li>
@endif
<li><a href="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/export/{{ $currentCanvas }}">{!! __('links.icon.export') !!}</a></li>
<li><a href="javascript:window.print();">{!! __('links.icon.print') !!}</a></li>
@if($login::userIsAtLeast($roles::$editor))
<li><a href="#/blueprints/{{ $canvasSlug }}/delCanvas/{{ $currentCanvas }}" class="delete">{!! __('links.icon.delete') !!}</a></li>
@endif
</ul>
</span>
</div>
@endif
</div><!--pageheader-->
<div class="maincontent">
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<div class="row">
<div class="col-md-3">
@if($login::userIsAtLeast($roles::$editor) && count($canvasTypes) == 1 && count($allCanvas) > 0)
<x-global::forms.button tag="a" link="#/blueprints/{{ $canvasSlug }}/editCanvasItem?type={{ $elementName }}"
contentRole="primary" id="{{ $elementName }}">{!! __('links.add_new_canvas_item' . $canvasSlug) !!}</x-global::forms.button>
@endif
</div>
<div class="col-md-6 center">
</div>
<div class="col-md-3">
<div class="pull-right">
<div class="btn-group viewDropDown">
@if(count($allCanvas) > 0 && ! empty($statusLabels))
@if($filter['status'] == 'all' || ! isset($statusLabels[$filter['status']]))
<button class="btn dropdown-toggle" data-toggle="dropdown"><i class="fas fa-filter"></i> {!! __('status.all') !!} {!! __('links.view') !!}</button>
@else
<button class="btn dropdown-toggle" data-toggle="dropdown"><i class="fas fa-fw {!! __($statusLabels[$filter['status']]['icon']) !!}"></i> {{ $statusLabels[$filter['status']]['title'] }} {!! __('links.view') !!}</button>
@endif
<ul class="dropdown-menu">
<li><a href="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/showCanvas?filter_status=all" @if($filter['status'] == 'all') class="active" @endif><i class="fas fa-globe"></i> {!! __('status.all') !!}</a></li>
@foreach($statusLabels as $key => $data)
<li><a href="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/showCanvas?filter_status={{ $key }}" @if($filter['status'] == $key) class="active" @endif><i class="fas fa-fw {{ $data['icon'] }}"></i> {{ $data['title'] }}</a></li>
@endforeach
</ul>
@endif
</div>
<div class="btn-group viewDropDown">
@if(count($allCanvas) > 0 && ! empty($relatesLabels))
@if($filter['relates'] == 'all' || ! isset($relatesLabels[$filter['relates']]))
<button class="btn dropdown-toggle" data-toggle="dropdown"><i class="fas fa-fw fa-globe"></i> {!! __('relates.all') !!} {!! __('links.view') !!}</button>
@else
<button class="btn dropdown-toggle" data-toggle="dropdown"><i class="fas fa-fw {!! __($relatesLabels[$filter['relates']]['icon']) !!}"></i> {{ $relatesLabels[$filter['relates']]['title'] }} {!! __('links.view') !!}</button>
@endif
<ul class="dropdown-menu">
<li><a href="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/showCanvas?filter_relates=all" @if($filter['relates'] == 'all') class="active" @endif><i class="fas fa-globe"></i> {!! __('relates.all') !!}</a></li>
@foreach($relatesLabels as $key => $data)
<li><a href="{{ BASE_URL }}/blueprints/{{ $canvasSlug }}/showCanvas?filter_relates={{ $key }}" @if($filter['relates'] == $key) class="active" @endif><i class="fas fa-fw {{ $data['icon'] }}"></i> {{ $data['title'] }}</a></li>
@endforeach
</ul>
@endif
</div>
</div>
</div>
</div>
<div class="clearfix"></div>

View File

View File

@@ -0,0 +1,73 @@
<?php
use Illuminate\Support\Facades\Route;
use Leantime\Domain\Blueprints\Controllers;
/*
|--------------------------------------------------------------------------
| Blueprints Domain Routes
|--------------------------------------------------------------------------
|
| Native Laravel routes bound directly to plain Blueprints controllers
| ([Controller::class, 'method']). The {canvasSlug} segment selects the
| YAML-defined canvas variant (resolved in each controller's constructor);
| the optional {id} segment is passed to the action as a typed argument.
|
| This replaces the former blueprintsDispatch() helper, which mirrored
| Frontcontroller::executeAction (verb dispatch + merged-$params + an
| $_GET['id'] superglobal injection). Laravel's router now does the verb
| dispatch (one route per verb) and the controllers read their input from
| the injected IncomingRequest.
|
*/
// Boards overview (absorbed from the former Strategy domain). Declared before the
// {canvasSlug} group so the literal "showBoards" path is never treated as a slug.
Route::get('/blueprints/showBoards', [Controllers\ShowBoards::class, 'get'])->name('blueprints.showBoards');
Route::prefix('blueprints/{canvasSlug}')->group(function () {
Route::get('/showCanvas/{id?}', [Controllers\ShowCanvas::class, 'get'])->name('blueprints.show');
Route::post('/showCanvas/{id?}', [Controllers\ShowCanvas::class, 'post'])->name('blueprints.show.post');
Route::get('/editCanvasItem/{id?}', [Controllers\EditCanvasItem::class, 'get'])->name('blueprints.editItem');
Route::post('/editCanvasItem/{id?}', [Controllers\EditCanvasItem::class, 'post'])->name('blueprints.editItem.post');
Route::get('/editCanvasComment/{id?}', [Controllers\EditCanvasComment::class, 'get'])->name('blueprints.editComment');
Route::post('/editCanvasComment/{id?}', [Controllers\EditCanvasComment::class, 'post'])->name('blueprints.editComment.post');
Route::get('/boardDialog/{id?}', [Controllers\BoardDialog::class, 'get'])->name('blueprints.boardDialog');
Route::post('/boardDialog/{id?}', [Controllers\BoardDialog::class, 'post'])->name('blueprints.boardDialog.post');
Route::get('/delCanvas/{id?}', [Controllers\DelCanvas::class, 'get'])->name('blueprints.delCanvas');
Route::post('/delCanvas/{id?}', [Controllers\DelCanvas::class, 'post'])->name('blueprints.delCanvas.post');
Route::get('/delCanvasItem/{id?}', [Controllers\DelCanvasItem::class, 'get'])->name('blueprints.delCanvasItem');
Route::post('/delCanvasItem/{id?}', [Controllers\DelCanvasItem::class, 'post'])->name('blueprints.delCanvasItem.post');
Route::get('/export/{id?}', [Controllers\Export::class, 'get'])->name('blueprints.export');
});
// Inline item updates (status / relates / assignee) from the board view.
Route::patch('/api/blueprints/{canvasSlug}', [Controllers\ApiCanvas::class, 'patch'])->name('blueprints.api.patch');
// Legacy redirect: the former /strategy/showBoards overview now lives in Blueprints.
// Preserve any query string, matching the other legacy redirects below.
Route::any('/strategy/showBoards/{id?}', function () {
$queryString = request()->getQueryString();
return redirect('/blueprints/showBoards'.($queryString ? '?'.$queryString : ''), 301);
});
// Legacy redirects: forward old /xxxcanvas/ URLs to /blueprints/xxx/
$legacySlugs = ['swot', 'lean', 'cp', 'dbm', 'ea', 'em', 'insights', 'lbm', 'minempathy', 'obm', 'retros', 'risks', 'sb', 'sm', 'sq', 'value'];
foreach ($legacySlugs as $slug) {
Route::any("/{$slug}canvas/{action?}/{id?}", function (string $action = 'showCanvas', ?string $id = null) use ($slug) {
$path = "/blueprints/{$slug}/{$action}";
if ($id) {
$path .= "/{$id}";
}
$queryString = request()->getQueryString();
return redirect($path.($queryString ? '?'.$queryString : ''), 301);
})->where('action', '[a-zA-Z]+');
}