Files
Leantime/app/Domain/Goalcanvas/Controllers/EditCanvasItem.php

420 lines
20 KiB
PHP

<?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');
}
}