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