OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
Some checks failed
Security Audit / Composer Security Audit (push) Has been cancelled
Security Audit / NPM Security Audit (push) Has been cancelled
Security Audit / CodeQL Analysis (push) Has been cancelled

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,90 @@
<?php
/**
* Controller / Edit Canvas Item
*/
namespace Leantime\Domain\Goalcanvas\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvaService;
use Symfony\Component\HttpFoundation\Response;
/**
* Goal board (big rock) create/edit dialog. Standalone, independent of the canvas domain.
*/
class BigRock extends Controller
{
private GoalcanvaService $goalService;
public function init(
GoalcanvaService $goalService
): void {
$this->goalService = $goalService;
}
/**
* @throws \Exception
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW, entityScoped: true)]
public function get($params): Response
{
if (isset($params['id'])) {
// getSingleCanvas authorizes VIEW against the board's real project and returns
// false for a missing/foreign/unauthorized board.
$bigrock = $this->goalService->getSingleCanvas($params['id']);
if ($bigrock === false) {
$bigrock = ['id' => '', 'title' => '', 'projectId' => '', 'author' => ''];
}
} else {
$bigrock = ['id' => '', 'title' => '', 'projectId' => '', 'author' => ''];
}
$this->tpl->assign('bigRock', $bigrock);
return $this->tpl->displayPartial('goalcanvas.bigRockDialog');
}
/**
* @throws BindingResolutionException
*/
#[RequiresPermission(GoalcanvasPermissions::EDIT, entityScoped: true)]
public function post($params): Response
{
$bigrock = ['id' => '', 'title' => '', 'projectId' => '', 'author' => ''];
if (isset($_GET['id'])) {
$id = (int) $_GET['id'];
// Update
$bigrock['id'] = $id;
$bigrock['title'] = $params['title'];
$this->goalService->updateGoalboard($bigrock);
$this->tpl->setNotification('notification.goalboard_updated_successfully', 'success', 'goalcanvas_updated');
return Frontcontroller::redirect(BASE_URL.'/goalcanvas/bigRock/'.$id);
} else {
// New
$bigrock['title'] = $params['title'];
$bigrock['projectId'] = session('currentProject');
$bigrock['author'] = session('userdata.id');
$id = $this->goalService->createGoalboard($bigrock);
if ($id) {
$this->tpl->setNotification('notification.goalboard_created_successfully', 'success', 'wiki_created');
return Frontcontroller::redirect(BASE_URL.'/goalcanvas/bigRock/'.$id.'?closeModal=1');
}
return Frontcontroller::redirect(BASE_URL.'/goalcanvas/bigRock/'.$id.'');
}
}
}

View File

@@ -0,0 +1,469 @@
<?php
namespace Leantime\Domain\Goalcanvas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\Mailer;
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas;
use Leantime\Domain\Projects\Services\Projects;
use Leantime\Domain\Queue\Repositories\Queue as QueueRepo;
use Symfony\Component\HttpFoundation\Response;
class Dashboard extends Controller
{
/**
* Constant that must be redefined.
*/
protected const CANVAS_NAME = 'goal';
private Projects $projectService;
private Goalcanvas $goalService;
private object $canvasRepo;
/**
* Initializes dependencies.
*/
public function init(
Projects $projectService,
Goalcanvas $goalService
): void {
$this->projectService = $projectService;
$this->goalService = $goalService;
$repoName = app()->getNamespace().'Domain\\goalcanvas\\Repositories\\goalcanvas';
$this->canvasRepo = app()->make($repoName);
}
/**
* Displays the goal canvas dashboard.
*
* @param array $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW)]
public function get(array $params): Response
{
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$allCanvas = $this->ensureDefaultCanvas($allCanvas);
$goalAnalytics = $this->calculateGoalAnalytics($allCanvas);
$currentCanvasId = $this->resolveCurrentCanvasId($allCanvas, $params);
if (isset($_REQUEST['searchCanvas'])) {
$currentCanvasId = (int) $_REQUEST['searchCanvas'];
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
$this->assignTemplateVars($currentCanvasId, $allCanvas, $goalAnalytics);
if (! isset($_GET['raw'])) {
return $this->tpl->display(static::CANVAS_NAME.'canvas.dashboard');
}
return new Response;
}
/**
* Handles goal canvas mutations (create, edit, clone, merge, import).
*
* @param array $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::EDIT)]
public function post(array $params): Response
{
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$allCanvas = $this->ensureDefaultCanvas($allCanvas);
$goalAnalytics = $this->calculateGoalAnalytics($allCanvas);
$currentCanvasId = $this->resolveCurrentCanvasId($allCanvas, $params);
if (isset($_POST['newCanvas'])) {
$result = $this->handleNewCanvas($currentCanvasId, $allCanvas);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['editCanvas']) && $currentCanvasId > 0) {
$result = $this->handleEditCanvas($currentCanvasId);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['cloneCanvas']) && $currentCanvasId > 0) {
$result = $this->handleCloneCanvas($currentCanvasId, $allCanvas);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['mergeCanvas']) && $currentCanvasId > 0) {
$result = $this->handleMergeCanvas($currentCanvasId);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['importCanvas'])) {
$result = $this->handleImportCanvas($currentCanvasId, $allCanvas);
if ($result !== null) {
return $result;
}
}
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$this->assignTemplateVars($currentCanvasId, $allCanvas, $goalAnalytics);
if (! isset($_GET['raw'])) {
return $this->tpl->display(static::CANVAS_NAME.'canvas.dashboard');
}
return new Response;
}
/**
* Creates a default canvas if none exist.
*/
private function ensureDefaultCanvas(array $allCanvas): array
{
if (! $allCanvas) {
$values = [
'title' => $this->language->__('label.board'),
'author' => session('userdata.id'),
'projectId' => session('currentProject'),
];
// View-time convenience: lazily create a default board (in the CURRENT project) when
// none exist. Kept repo-direct/UNGATED on purpose — gating it through createGoalboard's
// CREATE check would 403 a VIEW-only user just for opening an empty goals page. It is
// not an IDOR (always the session project). Same landmine pattern as the Blueprints/
// Wiki/Ideas default-board/notebook bootstrap.
$this->canvasRepo->addCanvas($values);
return $this->canvasRepo->getAllCanvas(session('currentProject'));
}
return $allCanvas;
}
/**
* Calculates goal analytics across all canvases.
*/
private function calculateGoalAnalytics(array $allCanvas): array
{
$goalAnalytics = [
'numCanvases' => count($allCanvas),
'numGoals' => '0',
'goalsOnTrack' => 0,
'goalsAtRisk' => 0,
'goalsMiss' => 0,
'avgPercentComplete' => '0',
];
$totalPercent = 0;
foreach ($allCanvas as $canvas) {
$canvasItems = $this->canvasRepo->getCanvasItemsById($canvas['id']);
foreach ($canvasItems as $item) {
$goalAnalytics['numGoals']++;
if ($item['status'] == 'status_ontrack') {
$goalAnalytics['goalsOnTrack']++;
}
if ($item['status'] == 'status_atrisk') {
$goalAnalytics['goalsAtRisk']++;
}
if ($item['status'] == 'status_miss') {
$goalAnalytics['goalsMiss']++;
}
$total = $item['endValue'] - $item['startValue'];
$progressValue = $item['currentValue'] - $item['startValue'];
if ($total != 0) {
$percentDone = max(0, min(100, round($progressValue / $total * 100, 2)));
} else {
$percentDone = 0;
}
$totalPercent = $totalPercent + $percentDone;
}
}
if ($goalAnalytics['numGoals'] > 0) {
$goalAnalytics['avgPercentComplete'] = $totalPercent / $goalAnalytics['numGoals'];
}
return $goalAnalytics;
}
/**
* Resolves the current canvas ID from session or request parameters.
*/
private function resolveCurrentCanvasId(array $allCanvas, array $params): int
{
$sessionKey = 'current'.strtoupper(static::CANVAS_NAME).'Canvas';
$currentCanvasId = -1;
if (session()->exists($sessionKey)) {
$currentCanvasId = 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 = $allCanvas[0]['id'];
session([$sessionKey => $currentCanvasId]);
}
if (isset($params['id'])) {
// Only honor an explicit board id that belongs to the CURRENT project's boards;
// a foreign/unknown id must not become the active board (cross-project item read).
$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 $currentCanvasId;
}
/**
* Handles creating a new canvas board.
*/
private function handleNewCanvas(int &$currentCanvasId, array &$allCanvas): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
if ($this->canvasRepo->existCanvas(session('currentProject'), $_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
return null;
}
$values = [
'title' => $_POST['canvastitle'],
'author' => session('userdata.id'),
'projectId' => session('currentProject'),
];
// createGoalboard authorizes CREATE against the target (current) project.
$currentCanvasId = $this->goalService->createGoalboard($values);
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$this->notifyBoardCreated($values['title'], 'notification.board_created', 'email_notifications.canvas_created_message');
$this->tpl->setNotification($this->language->__('notification.board_created'), 'success');
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
/**
* Handles editing a canvas board title.
*/
private function handleEditCanvas(int &$currentCanvasId): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
if ($this->canvasRepo->existCanvas(session('currentProject'), $_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
return null;
}
// updateGoalboard authorizes EDIT against the board's real project.
$values = ['title' => $_POST['canvastitle'], 'id' => $currentCanvasId];
$currentCanvasId = $this->goalService->updateGoalboard($values);
$this->tpl->setNotification($this->language->__('notification.board_edited'), 'success');
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
/**
* Handles cloning a canvas board.
*/
private function handleCloneCanvas(int &$currentCanvasId, array &$allCanvas): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
if ($this->canvasRepo->existCanvas(session('currentProject'), $_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
return null;
}
// copyGoalBoard authorizes VIEW on the source board's project and CREATE on the target.
$currentCanvasId = $this->goalService->copyGoalBoard(
$currentCanvasId,
(int) session('currentProject'),
(int) session('userdata.id'),
$_POST['canvastitle']
);
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$this->tpl->setNotification($this->language->__('notification.board_copied'), 'success');
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
/**
* Handles merging two canvas boards.
*/
private function handleMergeCanvas(int $currentCanvasId): ?Response
{
if (! isset($_POST['canvasid']) || $_POST['canvasid'] <= 0) {
$this->tpl->setNotification($this->language->__('notification.internal_error'), 'error');
return null;
}
// mergeGoalBoard authorizes EDIT on the target board's project and VIEW on the source's.
$status = $this->goalService->mergeGoalBoard($currentCanvasId, (int) $_POST['canvasid']);
if ($status) {
$this->tpl->setNotification($this->language->__('notification.board_merged'), 'success');
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
$this->tpl->setNotification($this->language->__('notification.merge_error'), 'error');
return null;
}
/**
* Handles importing a canvas from an XML file.
*/
private function handleImportCanvas(int &$currentCanvasId, array &$allCanvas): ?Response
{
if (! isset($_FILES['canvasfile']) || $_FILES['canvasfile']['error'] !== 0) {
return null;
}
$uploadfile = tempnam(sys_get_temp_dir(), 'leantime.').'.xml';
if (! move_uploaded_file($_FILES['canvasfile']['tmp_name'], $uploadfile)) {
$this->tpl->setNotification($this->language->__('notification.board_import_failed'), 'error');
return null;
}
$services = app()->make(BlueprintsService::class);
// Blueprints service expects the canvas slug (e.g. "goal"), not the full type ("goalcanvas").
$importCanvasId = $services->import(
$uploadfile,
static::CANVAS_NAME,
projectId: session('currentProject'),
authorId: session('userdata.id')
);
unlink($uploadfile);
if ($importCanvasId === false) {
$this->tpl->setNotification($this->language->__('notification.board_import_failed'), 'error');
return null;
}
$currentCanvasId = $importCanvasId;
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
// getSingleCanvas returns a single row (Goalcanvas repo override) or false.
$canvas = $this->goalService->getSingleCanvas($currentCanvasId);
$this->notifyBoardCreated(
strip_tags($canvas !== false ? ($canvas['title'] ?? '') : ''),
'notification.board_imported',
'email_notifications.canvas_imported_message'
);
$this->tpl->setNotification($this->language->__('notification.board_imported'), 'success');
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
/**
* Sends board creation/import notifications to project users.
*/
private function notifyBoardCreated(string $title, string $subjectKey, string $messageKey): void
{
$mailer = app()->make(Mailer::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($title).'</a>'
);
$mailer->setHtml($message);
$queue = app()->make(QueueRepo::class);
$queue->queueMessageToUsers(
$users,
$message,
$this->language->__($subjectKey),
session('currentProject')
);
}
/**
* Assigns common template variables.
*/
private function assignTemplateVars(int $currentCanvasId, array $allCanvas, array $goalAnalytics): void
{
$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']]);
$this->tpl->assign('filter', $filter);
$this->tpl->assign('currentCanvas', $currentCanvasId);
$this->tpl->assign('goalStats', $goalAnalytics);
$this->tpl->assign('canvasIcon', $this->canvasRepo->getIcon());
$this->tpl->assign('canvasTypes', $this->canvasRepo->getCanvasTypes());
$this->tpl->assign('statusLabels', $this->canvasRepo->getStatusLabels());
$this->tpl->assign('relatesLabels', $this->canvasRepo->getRelatesLabels());
$this->tpl->assign('dataLabels', $this->canvasRepo->getDataLabels());
$this->tpl->assign('disclaimer', $this->canvasRepo->getDisclaimer());
$this->tpl->assign('allCanvas', $allCanvas);
$this->tpl->assign('canvasItems', $this->goalService->getCanvasItemsById($currentCanvasId));
$this->tpl->assign('users', $this->projectService->getUsersAssignedToProject(session('currentProject')));
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Leantime\Domain\Goalcanvas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
use Leantime\Domain\Goalcanvas\Repositories\Goalcanvas as GoalcanvaRepository;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvaService;
use Symfony\Component\HttpFoundation\Response;
/**
* Handles deletion of a goal canvas board.
*/
class DelCanvas extends Controller
{
/**
* Constant that must be redefined.
*/
protected const CANVAS_NAME = 'goal';
private GoalcanvaRepository $canvasRepo;
private GoalcanvaService $goalService;
/**
* Initializes dependencies.
*/
public function init(GoalcanvaRepository $canvasRepo, GoalcanvaService $goalService): void
{
$this->canvasRepo = $canvasRepo;
$this->goalService = $goalService;
}
/**
* Displays the delete goal canvas confirmation.
*
* @param array $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::DELETE)]
public function get(array $params): Response
{
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
$this->tpl->assign('id', $id);
return $this->tpl->displayPartial(static::CANVAS_NAME.'canvas.delCanvas');
}
/**
* Handles goal canvas board deletion.
*
* @param array $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::DELETE, entityScoped: true)]
public function post(array $params): Response
{
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
if (isset($_POST['del']) && $id > 0) {
// The service resolves the board's REAL project and authorizes DELETE against it
// (throwing for a missing/foreign board) — closing the by-id board-delete IDOR the
// previous role-only Auth::authOrRedirect left open.
$this->goalService->deleteGoalBoard($id);
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $allCanvas[0]['id'] ?? -1]);
$this->tpl->setNotification($this->language->__('notification.board_deleted'), 'success', strtoupper(static::CANVAS_NAME).'canvas_deleted');
if (! $allCanvas) {
return Frontcontroller::redirect(BASE_URL.'/blueprints/showBoards');
}
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas');
}
$this->tpl->assign('id', $id);
return $this->tpl->displayPartial(static::CANVAS_NAME.'canvas.delCanvas');
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Leantime\Domain\Goalcanvas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvaService;
use Symfony\Component\HttpFoundation\Response;
/**
* Handles deletion of a goal canvas item.
*/
class DelCanvasItem extends Controller
{
/**
* Constant that must be redefined.
*/
protected const CANVAS_NAME = 'goal';
private GoalcanvaService $goalService;
/**
* Initializes dependencies.
*/
public function init(GoalcanvaService $goalService): void
{
$this->goalService = $goalService;
}
/**
* Displays the delete goal item confirmation.
*
* @param array $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::DELETE)]
public function get(array $params): Response
{
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
$this->tpl->assign('id', $id);
return $this->tpl->displayPartial(static::CANVAS_NAME.'canvas.delCanvasItem');
}
/**
* Handles goal item deletion.
*
* @param array $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::DELETE, entityScoped: true)]
public function post(array $params): Response
{
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
if (isset($_POST['del']) && $id > 0) {
// The service resolves the item's REAL project and authorizes DELETE against it
// (throwing for a missing/foreign item) — closing the by-id item-delete IDOR.
$this->goalService->deleteGoalItem($id);
$this->tpl->setNotification($this->language->__('notification.element_deleted'), 'success', strtoupper(static::CANVAS_NAME).'canvasitem_deleted');
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas');
}
$this->tpl->assign('id', $id);
return $this->tpl->displayPartial(static::CANVAS_NAME.'canvas.delCanvasItem');
}
}

View File

@@ -0,0 +1,278 @@
<?php
/**
* editCanvasComment class - Generic canvas controller / Edit Comments
*/
namespace Leantime\Domain\Goalcanvas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvaService;
use Leantime\Domain\Notifications\Models\Notification as NotificationModel;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
class EditCanvasComment extends Controller
{
/**
* Constant that must be redefined
*/
protected const CANVAS_NAME = 'goal';
private CommentRepository $commentsRepo;
private ProjectService $projectService;
private GoalcanvaService $goalService;
private object $canvasRepo;
/**
* init - initialize private variables
*/
public function init(
CommentRepository $commentsRepo,
ProjectService $projectService,
GoalcanvaService $goalService
) {
$this->commentsRepo = $commentsRepo;
$this->projectService = $projectService;
$this->goalService = $goalService;
$repoName = app()->getNamespace().'Domain\\goalcanvas\\Repositories\\goalcanvas';
$this->canvasRepo = app()->make($repoName);
}
/**
* get - handle get requests
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW, entityScoped: true)]
public function get($params)
{
$canvasTypes = $this->canvasRepo->getCanvasTypes();
if (isset($params['id'])) {
// Resolve + VIEW-authorize the item against its real project first.
$canvasItem = $this->goalService->getGoalItem((int) $params['id']);
if (! $canvasItem) {
return $this->tpl->displayPartial('errors.error404');
}
// Delete comment — only when it belongs to THIS gated item (module + moduleId).
if (isset($params['delComment']) === true) {
$commentId = (int) ($params['delComment']);
$comment = $this->commentsRepo->getComment($commentId);
if ($comment !== false
&& (string) $comment['module'] === static::CANVAS_NAME.'canvasitem'
&& (int) $comment['moduleId'] === (int) $canvasItem['id']) {
$this->commentsRepo->deleteComment($commentId);
$this->tpl->setNotification($this->language->__('notifications.comment_deleted'), 'success', strtoupper(static::CANVAS_NAME).'canvascomment_deleted');
}
}
$comments = $this->commentsRepo->getComments(static::CANVAS_NAME.'canvasitem', $canvasItem['id']);
$this->tpl->assign('numComments', $this->commentsRepo->countComments(static::CANVAS_NAME.'canvasitem', $canvasItem['id']));
} else {
if (isset($params['type'])) {
$type = strip_tags($params['type']);
} else {
$type = array_key_first($canvasTypes);
}
$canvasItem = [
'id' => '',
'box' => $type,
'description' => '',
'status' => array_key_first($this->canvasRepo->getStatusLabels()),
'relates' => array_key_first($this->canvasRepo->getRelatesLabels()),
'assumptions' => '',
'data' => '',
'conclusion' => '',
'milestoneHeadline' => '',
'milestoneId' => '',
];
$comments = [];
}
$this->tpl->assign('comments', $comments);
$this->tpl->assign('canvasTypes', $canvasTypes);
$this->tpl->assign('canvasItem', $canvasItem);
return $this->tpl->displayPartial(static::CANVAS_NAME.'canvas.canvasComment');
}
/**
* post - handle post requests
*/
#[RequiresPermission(GoalcanvasPermissions::EDIT, entityScoped: true)]
public function post($params)
{
if (isset($params['changeItem'])) {
if (isset($params['itemId']) && $params['itemId'] != '') {
if (isset($params['description']) && ! empty($params['description'])) {
$currentCanvasId = (int) session('current'.strtoupper(static::CANVAS_NAME).'Canvas');
$canvasItem = [
'box' => $params['box'],
'author' => session('userdata.id'),
'description' => $params['description'],
'status' => $params['status'],
'relates' => $params['relates'],
'assumptions' => $params['assumptions'],
'data' => $params['data'],
'conclusion' => $params['conclusion'],
'itemId' => $params['itemId'],
'id' => $params['itemId'],
'canvasId' => $currentCanvasId,
'milestoneId' => $params['milestoneId'],
'dependentMilstone' => '',
];
// Resolves the item's real project from itemId and authorizes EDIT there.
$this->goalService->updateGoalItem($canvasItem);
$comments = $this->commentsRepo->getComments(static::CANVAS_NAME.'canvasitem', $params['itemId']);
$this->tpl->assign('numComments', $this->commentsRepo->countComments(
static::CANVAS_NAME.'canvasitem',
$params['itemId']
));
$this->tpl->assign('comments', $comments);
$this->tpl->setNotification($this->language->__('notifications.canvas_item_updates'), 'success', strtoupper(static::CANVAS_NAME).'canvasitem_updated');
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => BASE_URL.'/'.static::CANVAS_NAME.'canvas'.'/editCanvasComment/'.(int) $params['itemId'],
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $canvasItem;
$notification->module = static::CANVAS_NAME.'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.'/'.static::CANVAS_NAME.'canvas'.'/editCanvasComment/'.$params['itemId']);
} else {
$this->tpl->setNotification($this->language->__('notification.please_enter_element_title'), 'error');
}
} else {
if (isset($_POST['description']) && ! empty($_POST['description'])) {
$currentCanvasId = (int) session('current'.strtoupper(static::CANVAS_NAME).'Canvas');
$canvasItem = [
'box' => $params['box'],
'author' => session('userdata.id'),
'description' => $params['description'],
'status' => $params['status'],
'relates' => $params['relates'],
'assumptions' => $params['assumptions'],
'data' => $params['data'],
'conclusion' => $params['conclusion'],
'canvasId' => $currentCanvasId,
];
// Resolves the target board's real project from canvasId and authorizes CREATE.
$id = $this->goalService->createGoalItem($canvasItem);
$canvasItem['id'] = $id;
$canvasTypes = $this->canvasRepo->getCanvasTypes();
$this->tpl->setNotification($canvasTypes[$params['box']].' successfully created', 'success', strtoupper(static::CANVAS_NAME).'canvasitem_created');
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => BASE_URL.'/'.static::CANVAS_NAME.'canvas'.'/editCanvasComment/'.(int) $params['itemId'],
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $canvasItem;
$notification->module = static::CANVAS_NAME.'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(static::CANVAS_NAME).'canvasitem_created');
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas'.'/editCanvasComment/'.$id);
} else {
$this->tpl->setNotification($this->language->__('notification.please_enter_element_title'), 'error');
}
}
}
if (isset($params['comment']) === true) {
// Only allow commenting on a goal item the user can view in their project.
if (! $this->goalService->getGoalItem((int) ($_GET['id'] ?? 0))) {
return $this->tpl->displayPartial('errors.error404');
}
$values = [
'text' => $params['text'],
'date' => date('Y-m-d H:i:s'),
'userId' => (session('userdata.id')),
'moduleId' => $_GET['id'],
'commentParent' => ($params['father']),
];
$message = $this->commentsRepo->addComment($values, static::CANVAS_NAME.'canvasitem');
$this->tpl->setNotification($this->language->__('notifications.comment_create_success'), 'success', strtoupper(static::CANVAS_NAME).'canvasitemcomment_created');
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => BASE_URL.'/'.static::CANVAS_NAME.'canvas'.'/editCanvasComment/'.(int) $_GET['id'],
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $values;
$notification->module = static::CANVAS_NAME.'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.'/'.static::CANVAS_NAME.'canvas'.'/editCanvasComment/'.$_GET['id']);
}
$this->tpl->assign('canvasTypes', $this->canvasRepo->getCanvasTypes());
$this->tpl->assign('canvasItem', $this->goalService->getGoalItem((int) ($_GET['id'] ?? 0)));
return $this->tpl->displayPartial(static::CANVAS_NAME.'canvas.canvasComment');
}
/**
* put - handle put requests
*/
public function put($params) {}
/**
* delete - handle delete requests
*/
public function delete($params) {}
}

View File

@@ -0,0 +1,419 @@
<?php
/**
* Controller / Edit Canvas Item
*/
namespace Leantime\Domain\Goalcanvas\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\Support\FromFormat;
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
use Leantime\Domain\Goalcanvas\Repositories\Goalcanvas as GoalcanvaRepository;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvaService;
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;
/**
* Goal canvas item editor. Standalone (own get()/post()), independent of the canvas domain.
*/
class EditCanvasItem extends Controller
{
protected const CANVAS_NAME = 'goal';
private GoalcanvaRepository $canvasRepo;
private CommentRepository $commentsRepo;
private TicketService $ticketService;
private ProjectService $projectService;
private GoalcanvaService $goalService;
public function init(
GoalcanvaRepository $canvasRepo,
CommentRepository $commentsRepo,
TicketService $ticketService,
ProjectService $projectService,
GoalcanvaService $goalService
): void {
$this->canvasRepo = $canvasRepo;
$this->commentsRepo = $commentsRepo;
$this->ticketService = $ticketService;
$this->projectService = $projectService;
$this->goalService = $goalService;
}
/**
* @throws \Exception
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW, entityScoped: true)]
public function get($params): Response
{
if (isset($params['id'])) {
// Resolve + VIEW-authorize the item against its real project BEFORE any mutation.
// false = missing / foreign project / unauthorized (indistinguishable -> no oracle).
$canvasItem = $this->goalService->getGoalItem((int) $params['id']);
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 the bind prevents deleting a
// foreign item's / project's comment.
if (isset($params['delComment'])) {
$commentId = (int) ($params['delComment']);
$comment = $this->commentsRepo->getComment($commentId);
if ($comment !== false
&& (string) $comment['module'] === 'goalcanvasitem'
&& (int) $comment['moduleId'] === (int) $canvasItem['id']) {
$this->commentsRepo->deleteComment($commentId);
$this->tpl->setNotification($this->language->__('notifications.comment_deleted'), 'success');
}
}
$comments = $this->commentsRepo->getComments('goalcanvasitem', $canvasItem['id']);
$this->tpl->assign(
'numComments',
$this->commentsRepo->countComments('goalcanvasitem', $canvasItem['id'])
);
} else {
$canvasItem = [
'id' => '',
'box' => 'goal',
'title' => '',
'description' => '',
'status' => array_key_first($this->canvasRepo->getStatusLabels()),
'relates' => '',
'startValue' => '',
'currentValue' => '',
'canvasId' => $_GET['canvasId'] ?? (int) session('currentGOALCanvas'),
'endValue' => '',
'kpi' => '',
'startDate' => '',
'endDate' => '',
'setting' => '',
'metricType' => '',
'assignedTo' => '',
'parent' => '',
];
$comments = [];
}
$this->tpl->assign('id', $canvasItem['id'] ?? '');
$this->tpl->assign('canvasId', $canvasItem['canvasId']);
$this->tpl->assign('comments', $comments);
// Scope the milestone options to the GOAL's real project (goal↔milestone
// is same-project), not the session project — the dialog can be opened
// for a goal outside the current project. New goals fall back to session.
$goalProjectId = $canvasItem['projectId'] ?? session('currentProject');
$allProjectMilestones = $this->ticketService->getAllMilestones(['sprint' => '', 'type' => 'milestone', 'currentProject' => $goalProjectId]);
$this->tpl->assign('milestones', $allProjectMilestones);
// Linked-milestone chips + status summary for the goal editor (edge model).
if (($canvasItem['id'] ?? '') !== '') {
$goalMilestones = $this->goalService->getGoalMilestones((int) $canvasItem['id']);
$this->tpl->assign('goalMilestones', $goalMilestones['milestones']);
$this->tpl->assign('milestoneSummary', $goalMilestones['summary']);
} else {
$this->tpl->assign('goalMilestones', []);
$this->tpl->assign('milestoneSummary', ['total' => 0, 'done' => 0, 'inProgress' => 0, 'notStarted' => 0]);
}
$this->tpl->assign('currentCanvas', $canvasItem['canvasId']);
$this->tpl->assign('canvasItem', $canvasItem);
$this->tpl->assign('canvasIcon', $this->canvasRepo->getIcon());
$this->tpl->assign('canvasTypes', $this->canvasRepo->getCanvasTypes());
$this->tpl->assign('statusLabels', $this->canvasRepo->getStatusLabels());
$this->tpl->assign('dataLabels', $this->canvasRepo->getDataLabels());
return $this->tpl->displayPartial('goalcanvas.canvasDialog');
}
/**
* @throws BindingResolutionException
*/
#[RequiresPermission(GoalcanvasPermissions::EDIT, entityScoped: true)]
public function post($params): Response
{
// Detach a milestone edge. State-changing, so it goes through POST (not a
// GET link) with a CSRF token — the chip's remove control is an hx-post.
// Authorized by the service (EDIT against the item's real project; a
// view-only user is denied). Returns the re-rendered milestones section
// (hx-target="#goalMsSection" outerHTML) so the summary counts and
// scroll arrow update with the removed chip, not just the chip node.
if (isset($params['removeMilestone']) && isset($params['id'])) {
$itemId = (int) $params['id'];
$this->goalService->removeMilestoneFromGoal($itemId, (int) $params['removeMilestone']);
// getGoalItem() always stamps the goal's REAL projectId on success;
// false only for a missing/foreign/unauthorized goal — fail closed
// rather than fall back to the SESSION project, which could load
// another project's milestone options into the re-rendered picker.
$canvasItem = $this->goalService->getGoalItem($itemId);
if (! $canvasItem) {
return $this->tpl->displayPartial('errors.error404');
}
$goalMilestones = $this->goalService->getGoalMilestones($itemId);
$this->tpl->assign('id', $itemId);
$this->tpl->assign('goalMilestones', $goalMilestones['milestones']);
$this->tpl->assign('milestoneSummary', $goalMilestones['summary']);
$this->tpl->assign('milestones', $this->ticketService->getAllMilestones([
'sprint' => '', 'type' => 'milestone',
'currentProject' => $canvasItem['projectId'],
]));
return $this->tpl->displayPartial('goalcanvas::partials.milestonesSection');
}
if (isset($params['comment']) && isset($params['id'])) {
$itemId = (int) $params['id'];
// Only allow commenting on a goal item the user can view in their project.
if (! $this->goalService->getGoalItem($itemId)) {
return $this->tpl->displayPartial('errors.error404');
}
$values = [
'text' => $params['text'],
'date' => date('Y-m-d H:i:s'),
'userId' => (session('userdata.id')),
'moduleId' => $itemId,
'commentParent' => ($params['father']),
];
if ($params['text'] != '') {
$commentId = $this->commentsRepo->addComment($values, 'goalcanvasitem');
$this->tpl->setNotification($this->language->__('notifications.comment_create_success'), 'success');
$values['id'] = $commentId;
$subject = $this->language->__('email_notifications.canvas_board_comment_created');
$actual_link = BASE_URL.'#/goalcanvas/editCanvasItem/'.$itemId;
$message = sprintf(
$this->language->__('email_notifications.canvas_item__comment_created_message'),
session('userdata.name')
);
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => $actual_link,
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $values;
$notification->module = 'goalcanvas';
$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.'/goalcanvas/editCanvasItem/'.$itemId);
}
}
if (isset($params['changeItem'])) {
$currentCanvasId = $params['canvasId'] ?? (int) session('current'.strtoupper(static::CANVAS_NAME).'Canvas');
if (isset($params['itemId']) && ! empty($params['itemId'])) {
if (isset($params['title']) && ! empty($params['title'])) {
$canvasItem = [
'box' => $params['box'],
'author' => session('userdata.id'),
'title' => $params['title'],
'description' => $params['description'] ?? '',
'status' => $params['status'] ?? '',
'relates' => '',
'startValue' => $params['startValue'] ?? '',
'currentValue' => $params['currentValue'] ?? '',
'endValue' => $params['endValue'] ?? '',
'itemId' => $params['itemId'],
'canvasId' => $params['canvasId'],
'parent' => $params['parent'] ?? null,
'id' => $params['itemId'],
'kpi' => $params['kpi'] ?? '',
'startDate' => format(value: $params['startDate'] ?? '', fromFormat: FromFormat::UserDateStartOfDay)->isoDateTime(),
'endDate' => format(value: $params['endDate'] ?? '', fromFormat: FromFormat::UserDateEndOfDay)->isoDateTime(),
'setting' => $params['setting'] ?? '',
'metricType' => $params['metricType'] ?? '',
'assignedTo' => $params['assignedTo'] ?? '',
// milestoneId intentionally omitted — milestone links are
// now edges, managed by add/removeMilestoneToGoal, so a
// goal save must not reconcile them down to one value.
];
// Resolves the item's real project from itemId and authorizes EDIT there.
$this->goalService->updateGoalItem($canvasItem);
// Append a milestone link (new or existing) — leaves the
// goal's other linked milestones intact.
$milestoneToLink = 0;
if (isset($params['newMilestone']) && $params['newMilestone'] != '') {
// Create the milestone in the GOAL's real project (goal↔milestone
// is same-project), not the session project — otherwise a
// cross-project dialog would create it in the wrong project and
// the same-project link guard would then reject it.
$goalItem = $this->goalService->getGoalItem((int) $params['itemId']);
$params['projectId'] = ($goalItem['projectId'] ?? null) ?: session('currentProject');
$params['headline'] = $params['newMilestone'];
$params['tags'] = '#ccc';
$params['editFrom'] = dtHelper()->userNow()->formatDateForUser();
$params['editTo'] = dtHelper()->userNow()->addDays(7)->formatDateForUser();
$params['dependentMilestone'] = '';
$newId = $this->ticketService->quickAddMilestone($params);
if ($newId !== false) {
$milestoneToLink = (int) $newId;
}
} elseif (isset($params['existingMilestone']) && $params['existingMilestone'] != '') {
$milestoneToLink = (int) $params['existingMilestone'];
}
if ($milestoneToLink > 0) {
$this->goalService->addMilestoneToGoal((int) $params['itemId'], $milestoneToLink);
}
$comments = $this->commentsRepo->getComments('goalcanvasitem', $params['itemId']);
$this->tpl->assign('numComments', $this->commentsRepo->countComments(
'goalcanvasitem',
$params['itemId']
));
$this->tpl->assign('comments', $comments);
$this->tpl->setNotification($this->language->__('notifications.canvas_item_updates'), 'success', 'goal_created');
$subject = $this->language->__('email_notifications.canvas_board_edited');
$actual_link = BASE_URL.'#/goalcanvas/editCanvasItem/'.(int) $params['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' => $actual_link,
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $canvasItem;
$notification->module = 'goalcanvas';
$notification->action = 'updated';
$notification->projectId = session('currentProject');
$notification->subject = $subject;
$notification->authorId = session('userdata.id');
$notification->message = $message;
$this->projectService->notifyProjectUsers($notification);
} else {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
}
return Frontcontroller::redirect(BASE_URL.'/goalcanvas/editCanvasItem/'.$params['itemId']);
} else {
if (isset($_POST['title']) && ! empty($_POST['title'])) {
$canvasItem = [
'box' => $params['box'],
'author' => session('userdata.id'),
'title' => $params['title'],
'description' => $params['description'] ?? '',
'status' => $params['status'] ?? '',
'relates' => '',
'startValue' => $params['startValue'] ?? '',
'currentValue' => $params['currentValue'] ?? '',
'endValue' => $params['endValue'] ?? '',
'canvasId' => $params['canvasId'],
'parent' => $params['parent'] ?? null,
'kpi' => $params['kpi'] ?? '',
'startDate' => format(value: $params['startDate'] ?? '', fromFormat: FromFormat::UserDateStartOfDay)->isoDateTime(),
'endDate' => format(value: $params['endDate'] ?? '', fromFormat: FromFormat::UserDateEndOfDay)->isoDateTime(),
'setting' => $params['setting'] ?? '',
'metricType' => $params['metricType'] ?? '',
'assignedTo' => $params['assignedTo'] ?? '',
];
// Resolves the target board's real project from canvasId and authorizes CREATE.
$id = $this->goalService->createGoalItem($canvasItem);
$canvasTypes = $this->canvasRepo->getCanvasTypes();
$this->tpl->setNotification($canvasTypes[$params['box']]['title'].' successfully created', 'success', 'goal_item_created');
$subject = $this->language->__('email_notifications.canvas_board_item_created');
$actual_link = BASE_URL.'#/goalcanvas/editCanvasItem/'.(int) $params['itemId'];
$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' => $actual_link,
'text' => $this->language->__('email_notifications.canvas_item_update_cta'),
];
$notification->entity = $canvasItem;
$notification->module = 'goalcanvas';
$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');
} else {
$id = '';
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
}
return Frontcontroller::redirect(BASE_URL.'/goalcanvas/editCanvasItem/'.$id);
}
}
$this->tpl->assign('canvasTypes', $this->canvasRepo->getCanvasTypes());
$this->tpl->assign('statusLabels', $this->canvasRepo->getStatusLabels());
$this->tpl->assign('dataLabels', $this->canvasRepo->getDataLabels());
if (isset($params['id'])) {
$canvasItemId = (int) $params['id'];
$comments = $this->commentsRepo->getComments('goalcanvasitem', $canvasItemId);
$this->tpl->assign('canvasItem', $this->goalService->getGoalItem($canvasItemId));
} else {
$value = [
'id' => '',
'box' => $params['box'],
'author' => session('userdata.id'),
'title' => '',
'description' => '',
'status' => array_key_first($this->canvasRepo->getStatusLabels()),
'relates' => array_key_first($this->canvasRepo->getRelatesLabels()),
'startValue' => '',
'currentValue' => '',
'endValue' => '',
'kpi' => '',
'startDate' => '',
'endDate' => '',
'setting ' => '',
'metricType' => '',
'assignedTo' => session('userdata.id'),
];
$comments = [];
$this->tpl->assign('canvasItem', $value);
}
$this->tpl->assign('comments', $comments);
return $this->tpl->displayPartial('goalcanvas.editCanvasItem');
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Goalcanvas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
use Leantime\Domain\Goalcanvas\Repositories\Goalcanvas as GoalcanvaRepository;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas as GoalcanvaService;
use Symfony\Component\HttpFoundation\Response;
/**
* Goal canvas XML export. Standalone, independent of the canvas domain.
*/
class Export extends Controller
{
private const CANVAS_TYPE = 'goalcanvas';
private const SESSION_KEY = 'currentGOALCanvas';
private GoalcanvaRepository $canvasRepo;
private GoalcanvaService $goalService;
/**
* init - resolve dependencies.
*
* @param GoalcanvaRepository $canvasRepo Goal canvas repository (label definitions)
* @param GoalcanvaService $goalService Goal canvas service (VIEW-authorized board reads)
*/
public function init(GoalcanvaRepository $canvasRepo, GoalcanvaService $goalService): void
{
$this->canvasRepo = $canvasRepo;
$this->goalService = $goalService;
}
/**
* get - generate and return the goal canvas as an XML file.
*
* @param array<string, mixed> $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW, entityScoped: true)]
public function get(array $params): Response
{
if (isset($params['id']) && $params['id'] !== '') {
$canvasId = (int) $params['id'];
} elseif (session()->exists(self::SESSION_KEY)) {
$canvasId = (int) session(self::SESSION_KEY);
} else {
return new Response('', 204);
}
// getSingleCanvas authorizes VIEW against the board's real project and returns false for
// a missing/foreign/unauthorized board (export is reachable by arbitrary board id).
$canvas = $this->goalService->getSingleCanvas($canvasId);
if (! $canvas) {
return new Response('Canvas not found', 404);
}
$records = $this->goalService->getCanvasItemsById($canvasId);
$canvasTypes = $this->canvasRepo->getCanvasTypes();
// The Goalcanvas repo's getSingleCanvas returns a single row (not array-of-rows).
$exportData = $this->buildXml($canvas['title'] ?? '', $records, $canvasTypes);
clearstatcache();
$response = new Response($exportData);
$response->headers->set('Content-type', 'application/xml');
$response->headers->set('Content-Disposition', 'attachment; filename="'.self::CANVAS_TYPE.'-'.$canvasId.'.xml"');
$response->headers->set('Cache-Control', 'no-cache');
return $response;
}
/**
* buildXml - render the canvas board and its items as XML.
*
* @param string $canvasTitle Board title
* @param array<int, array<string, mixed>> $records Canvas item records
* @param array<string, array<string, mixed>> $canvasTypes Translated box definitions
*/
private function buildXml(string $canvasTitle, array $records, array $canvasTypes): string
{
$tab = str_repeat(' ', 4);
$xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>'.PHP_EOL.PHP_EOL;
$xml .= '<canvas key="'.self::CANVAS_TYPE.'">'.PHP_EOL;
$xml .= $tab.'<title>'.$canvasTitle.'</title>'.PHP_EOL;
$xml .= $tab.'<content>'.PHP_EOL;
foreach ($canvasTypes as $key => $data) {
$xml .= $tab.$tab.'<element key="'.$key.'">'.PHP_EOL;
foreach ($records as $record) {
if ($record['box'] === $key) {
$xml .= $tab.$tab.$tab.'<item>'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<created>'.($record['created'] ?? '').'</created>'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<modified>'.($record['modified'] ?? '').'</modified>'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<author id="'.($record['author'] ?? '').'" firstname="'.($record['authorFirstname'] ?? '').'" '.
'lastname="'.($record['authorLastname'] ?? '').'"/>'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<description>'.($record['description'] ?? '').'</description>'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<status key="'.($record['status'] ?? '').'" />'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<relates key="'.($record['relates'] ?? '').'" />'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<assumptions>'.($record['assumptions'] ?? '').'</assumptions>'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<data>'.($record['data'] ?? '').'</data>'.PHP_EOL;
$xml .= $tab.$tab.$tab.$tab.'<conclusion>'.($record['conclusion'] ?? '').'</conclusion>'.PHP_EOL;
$xml .= $tab.$tab.$tab.'</item>'.PHP_EOL;
}
}
$xml .= $tab.$tab.'</element>'.PHP_EOL;
}
$xml .= $tab.'</content>'.PHP_EOL;
$xml .= '</canvas>'.PHP_EOL;
return $xml;
}
}

View File

@@ -0,0 +1,397 @@
<?php
namespace Leantime\Domain\Goalcanvas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\Mailer;
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
use Leantime\Domain\Goalcanvas\Permissions\GoalcanvasPermissions;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas;
use Leantime\Domain\Projects\Services\Projects;
use Leantime\Domain\Queue\Repositories\Queue as QueueRepo;
use Symfony\Component\HttpFoundation\Response;
class ShowCanvas extends Controller
{
/**
* Constant that must be redefined.
*/
protected const CANVAS_NAME = 'goal';
private $canvasRepo;
private Projects $projectService;
private Goalcanvas $goalService;
/**
* Initializes dependencies.
*/
public function init(Projects $projectService, Goalcanvas $goalService): void
{
$this->projectService = $projectService;
$this->goalService = $goalService;
$repoName = app()->getNamespace().'Domain\\goalcanvas\\Repositories\\goalcanvas';
$this->canvasRepo = app()->make($repoName);
}
/**
* Displays the goal canvas board view.
*
* @param array $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::VIEW)]
public function get(array $params): Response
{
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$currentCanvasId = $this->resolveCurrentCanvasId($allCanvas, $params);
if (isset($_REQUEST['searchCanvas'])) {
$currentCanvasId = (int) $_REQUEST['searchCanvas'];
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
$this->assignTemplateVars($currentCanvasId, $allCanvas);
if (! isset($_GET['raw'])) {
return $this->tpl->display(static::CANVAS_NAME.'canvas.showCanvas');
}
return new Response;
}
/**
* Handles goal canvas mutations (create, edit, clone, merge, import).
*
* @param array $params Request parameters
*/
#[RequiresPermission(GoalcanvasPermissions::EDIT)]
public function post(array $params): Response
{
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$currentCanvasId = $this->resolveCurrentCanvasId($allCanvas, $params);
if (isset($_POST['newCanvas'])) {
$result = $this->handleNewCanvas($currentCanvasId, $allCanvas);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['editCanvas']) && $currentCanvasId > 0) {
$result = $this->handleEditCanvas($currentCanvasId);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['cloneCanvas']) && $currentCanvasId > 0) {
$result = $this->handleCloneCanvas($currentCanvasId, $allCanvas);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['mergeCanvas']) && $currentCanvasId > 0) {
$result = $this->handleMergeCanvas($currentCanvasId);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['importCanvas'])) {
$result = $this->handleImportCanvas($currentCanvasId, $allCanvas);
if ($result !== null) {
return $result;
}
}
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$this->assignTemplateVars($currentCanvasId, $allCanvas);
if (! isset($_GET['raw'])) {
return $this->tpl->display(static::CANVAS_NAME.'canvas.showCanvas');
}
return new Response;
}
/**
* Resolves the current canvas ID from session, GET params, or creates a default.
*/
private function resolveCurrentCanvasId(array &$allCanvas, array $params): int
{
$sessionKey = 'current'.strtoupper(static::CANVAS_NAME).'Canvas';
if (! $allCanvas) {
$values = [
'title' => $this->language->__('label.board'),
'author' => session('userdata.id'),
'projectId' => session('currentProject'),
];
// View-time convenience: lazily create a default board (in the CURRENT project) when
// none exist. Kept repo-direct/UNGATED on purpose — gating it through createGoalboard's
// CREATE check would 403 a VIEW-only user just for opening an empty goals page. It is
// not an IDOR (always the session project). Same landmine pattern as the Blueprints/
// Wiki/Ideas default-board/notebook bootstrap.
$currentCanvasId = $this->canvasRepo->addCanvas($values);
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
return $currentCanvasId;
}
$currentCanvasId = -1;
if (session()->exists($sessionKey)) {
$currentCanvasId = 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 (session($sessionKey) == '') {
$currentCanvasId = $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 assignTemplateVars would read another project's items.
$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 $currentCanvasId;
}
/**
* Handles creating a new canvas board.
*/
private function handleNewCanvas(int &$currentCanvasId, array &$allCanvas): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
if ($this->canvasRepo->existCanvas(session('currentProject'), $_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
return null;
}
$values = [
'title' => $_POST['canvastitle'],
'author' => session('userdata.id'),
'projectId' => session('currentProject'),
];
// createGoalboard authorizes CREATE against the target (current) project.
$currentCanvasId = $this->goalService->createGoalboard($values);
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$this->notifyBoardCreated($values['title'], 'notification.board_created', 'email_notifications.canvas_created_message');
$this->tpl->setNotification($this->language->__('notification.board_created'), 'success');
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
/**
* Handles editing a canvas board title.
*/
private function handleEditCanvas(int &$currentCanvasId): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
if ($this->canvasRepo->existCanvas(session('currentProject'), $_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
return null;
}
// updateGoalboard authorizes EDIT against the board's real project.
$values = ['title' => $_POST['canvastitle'], 'id' => $currentCanvasId];
$currentCanvasId = $this->goalService->updateGoalboard($values);
$this->tpl->setNotification($this->language->__('notification.board_edited'), 'success');
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
/**
* Handles cloning a canvas board.
*/
private function handleCloneCanvas(int &$currentCanvasId, array &$allCanvas): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
if ($this->canvasRepo->existCanvas(session('currentProject'), $_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.board_exists'), 'error');
return null;
}
// copyGoalBoard authorizes VIEW on the source board's project and CREATE on the target.
$currentCanvasId = $this->goalService->copyGoalBoard(
$currentCanvasId,
(int) session('currentProject'),
(int) session('userdata.id'),
$_POST['canvastitle']
);
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
$this->tpl->setNotification($this->language->__('notification.board_copied'), 'success');
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
/**
* Handles merging two canvas boards.
*/
private function handleMergeCanvas(int $currentCanvasId): ?Response
{
if (! isset($_POST['canvasid']) || $_POST['canvasid'] <= 0) {
$this->tpl->setNotification($this->language->__('notification.internal_error'), 'error');
return null;
}
// mergeGoalBoard authorizes EDIT on the target board's project and VIEW on the source's.
$status = $this->goalService->mergeGoalBoard($currentCanvasId, (int) $_POST['canvasid']);
if ($status) {
$this->tpl->setNotification($this->language->__('notification.board_merged'), 'success');
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
$this->tpl->setNotification($this->language->__('notification.merge_error'), 'error');
return null;
}
/**
* Handles importing a canvas from an XML file.
*/
private function handleImportCanvas(int &$currentCanvasId, array &$allCanvas): ?Response
{
if (! isset($_FILES['canvasfile']) || $_FILES['canvasfile']['error'] !== 0) {
return null;
}
$uploadfile = tempnam(sys_get_temp_dir(), 'leantime.').'.xml';
if (! move_uploaded_file($_FILES['canvasfile']['tmp_name'], $uploadfile)) {
$this->tpl->setNotification($this->language->__('notification.board_import_failed'), 'error');
return null;
}
$services = app()->make(BlueprintsService::class);
// Blueprints service expects the canvas slug (e.g. "goal"), not the full type ("goalcanvas").
$importCanvasId = $services->import(
$uploadfile,
static::CANVAS_NAME,
projectId: session('currentProject'),
authorId: session('userdata.id')
);
unlink($uploadfile);
if ($importCanvasId === false) {
$this->tpl->setNotification($this->language->__('notification.board_import_failed'), 'error');
return null;
}
$currentCanvasId = $importCanvasId;
$allCanvas = $this->canvasRepo->getAllCanvas(session('currentProject'));
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
// The just-imported board is in the current project; getSingleCanvas returns a single
// row (Goalcanvas repo override) or false.
$canvas = $this->goalService->getSingleCanvas($currentCanvasId);
$this->notifyBoardCreated(
strip_tags($canvas !== false ? ($canvas['title'] ?? '') : ''),
'notification.board_imported',
'email_notifications.canvas_imported_message'
);
$this->tpl->setNotification($this->language->__('notification.board_imported'), 'success');
return Frontcontroller::redirect(BASE_URL.'/'.static::CANVAS_NAME.'canvas/showCanvas/');
}
/**
* Sends board creation/import notifications to project users.
*/
private function notifyBoardCreated(string $title, string $subjectKey, string $messageKey): void
{
$mailer = app()->make(Mailer::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($title).'</a>'
);
$mailer->setHtml($message);
$queue = app()->make(QueueRepo::class);
$queue->queueMessageToUsers(
$users,
$message,
$this->language->__($subjectKey),
session('currentProject')
);
}
/**
* Assigns common template variables.
*/
private function assignTemplateVars(int $currentCanvasId, array $allCanvas): void
{
$this->tpl->assign('currentCanvas', $currentCanvasId);
$this->tpl->assign('canvasIcon', $this->canvasRepo->getIcon());
$this->tpl->assign('canvasTypes', $this->canvasRepo->getCanvasTypes());
$this->tpl->assign('statusLabels', $this->canvasRepo->getStatusLabels());
$this->tpl->assign('relatesLabels', $this->canvasRepo->getRelatesLabels());
$this->tpl->assign('dataLabels', $this->canvasRepo->getDataLabels());
$this->tpl->assign('disclaimer', $this->canvasRepo->getDisclaimer());
$this->tpl->assign('allCanvas', $allCanvas);
$this->tpl->assign('canvasItems', $this->goalService->getCanvasItemsById($currentCanvasId));
$this->tpl->assign('users', $this->projectService->getUsersAssignedToProject(session('currentProject')));
}
}