Files
Leantime/app/Domain/Goalcanvas/Repositories/Goalcanvas.php

1555 lines
60 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Repository
*/
namespace Leantime\Domain\Goalcanvas\Repositories;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Db\DatabaseHelper;
use Leantime\Core\Db\Db as DbCore;
use Leantime\Core\Language as LanguageCore;
use Leantime\Core\Support\EntityRelationshipEnum;
use Leantime\Domain\Blueprints\Repositories\Blueprints;
use Leantime\Domain\Tickets\Repositories\Tickets;
class Goalcanvas extends Blueprints
{
/**
* Constant that must be redefined
*/
protected const CANVAS_NAME = 'goal';
/***
* icon - Icon associated with canvas (must be extended)
*
* @access protected
* @var string Fontawesome icone
*/
protected string $icon = 'fa-bullseye';
/***
* disclaimer - Disclaimer (may be extended)
*
* @access protected
* @var string Disclaimer (including href)
*/
protected string $disclaimer = '';
/**
* canvasTypes - Must be extended
*
* @acces protected
*/
protected array $canvasTypes = [
'goal' => ['icon' => 'fa-bullseye', 'title' => 'box.goal'],
];
/**
* statusLabels - Status labels (may be extended)
*
* @acces protected
*/
protected array $statusLabels = [
'status_ontrack' => ['icon' => 'fa-circle-check', 'color' => 'green', 'title' => 'status.goal.ontrack', 'dropdown' => 'success', 'active' => true],
'status_atrisk' => ['icon' => 'fa-triangle-exclamation', 'color' => 'yellow', 'title' => 'status.goal.atrisk', 'dropdown' => 'warning', 'active' => true],
'status_miss' => ['icon' => 'fa-circle-xmark', 'color' => 'red', 'title' => 'status.goal.miss', 'dropdown' => 'danger', 'active' => true],
];
protected array $relatesLabels = [];
/**
* dataLabels - Data labels (may be extended)
*
* @acces protected
*/
protected array $dataLabels = [
1 => ['title' => 'label.what_are_you_measuring', 'field' => 'assumptions', 'type' => 'string', 'active' => true],
2 => ['title' => 'label.current_value', 'field' => 'data', 'type' => 'int', 'active' => true],
3 => ['title' => 'label.goal_value', 'field' => 'conclusion', 'type' => 'int', 'active' => true],
];
protected ConnectionInterface $dbConnection;
/**
* Language service used by the config-label accessors below.
*/
protected LanguageCore $canvasLanguage;
/**
* Ticket repository — used by the milestone-chip hydration
* (getMilestonesForGoals / getMilestoneProgressForIds) for status labels.
*/
protected Tickets $ticketRepository;
/**
* @param DbCore $db Database connection
* @param LanguageCore $language Language service
* @param Tickets $ticketRepo Ticket repository
* @param DatabaseHelper $dbHelper Database helper
*/
public function __construct(DbCore $db, LanguageCore $language, Tickets $ticketRepo, DatabaseHelper $dbHelper)
{
parent::__construct($db, $ticketRepo, $dbHelper);
$this->dbConnection = $db->getConnection();
$this->canvasLanguage = $language;
$this->ticketRepository = $ticketRepo;
}
/**
* getIcon() - Retrieve canvas icon
*
* @return string Canvas icon
*/
public function getIcon(): string
{
return $this->icon;
}
/**
* getDisclaimer() - Retrieve disclaimer
*
* @return string Canvas disclaimer
*/
public function getDisclaimer(): string
{
if (empty($this->disclaimer)) {
return '';
}
return $this->canvasLanguage->__($this->disclaimer);
}
/**
* getCanvasTypes() - Retrieve translated canvas items
*
* @return array Array of data
*/
public function getCanvasTypes(): array
{
$canvasTypes = $this->canvasTypes;
foreach ($canvasTypes as $key => $data) {
if (isset($data['title'])) {
$canvasTypes[$key]['title'] = $this->canvasLanguage->__($data['title']);
}
}
return $canvasTypes;
}
/**
* getStatusLabels() - Retrieve translated status labels
*
* @return array Array of data
*/
public function getStatusLabels(): array
{
$statusLabels = $this->statusLabels;
foreach ($statusLabels as $key => $data) {
if (isset($data['title'])) {
$statusLabels[$key]['title'] = $this->canvasLanguage->__($data['title']);
}
}
return $statusLabels;
}
/**
* getRelatesLabels() - Retrieve translated relates labels
*
* @return array Array of data
*/
public function getRelatesLabels(): array
{
$relatesLabels = $this->relatesLabels;
foreach ($relatesLabels as $key => $data) {
if (isset($data['title'])) {
$relatesLabels[$key]['title'] = $this->canvasLanguage->__($data['title']);
}
}
return $relatesLabels;
}
/**
* getDataLabels() - Retrieve translated data labels
*
* @return array Array of data
*/
public function getDataLabels(): array
{
$dataLabels = $this->dataLabels;
foreach ($dataLabels as $key => $data) {
if (isset($data['title'])) {
$dataLabels[$key]['title'] = $this->canvasLanguage->__($data['title']);
}
}
return $dataLabels;
}
/**
* getAllCanvas - thin override defaulting the canvas type to "goalcanvas".
*
* Keeps the parent's optional type parameter so the signature stays compatible.
*
* @param int $projectId Project identifier
* @param string|null $canvasType Canvas type override (defaults to "goalcanvas")
* @return false|array<int, array<string, mixed>>
*/
public function getAllCanvas($projectId, $canvasType = null): false|array
{
$type = ($canvasType === null || $canvasType === '') ? static::CANVAS_NAME.'canvas' : $canvasType;
return parent::getAllCanvas((int) $projectId, $type);
}
/**
* addCanvas - thin override defaulting the canvas type to "goalcanvas".
*
* Keeps the parent's optional type parameter so the signature stays compatible.
*
* @param array<string, mixed> $values Canvas values
* @param string|null $canvasType Canvas type override (defaults to "goalcanvas")
*/
public function addCanvas($values, $canvasType = null): false|string
{
$type = ($canvasType === null || $canvasType === '') ? static::CANVAS_NAME.'canvas' : $canvasType;
return parent::addCanvas($values, $type);
}
/**
* getCanvasItemsById - thin override defaulting the comment module to "goalcanvasitem".
*
* Keeps the parent's optional module parameter so the signature stays compatible.
*
* @param int $id Canvas board identifier
* @param string|null $commentModule Comment module override (defaults to "goalcanvasitem")
* @return false|array<int, array<string, mixed>>
*/
public function getCanvasItemsById($id, $commentModule = null): false|array
{
$module = ($commentModule === null || $commentModule === '') ? static::CANVAS_NAME.'canvasitem' : $commentModule;
return parent::getCanvasItemsById((int) $id, $module);
}
/**
* existCanvas - thin override defaulting the canvas type to "goalcanvas".
*
* Keeps the parent's optional type parameter so the signature stays compatible.
*
* @param int $projectId Project identifier
* @param string $canvasTitle Canvas title
* @param string|null $canvasType Canvas type override (defaults to "goalcanvas")
* @return bool True if a canvas with the given title exists
*/
public function existCanvas(int $projectId, string $canvasTitle, $canvasType = null): bool
{
$type = ($canvasType === null || $canvasType === '') ? static::CANVAS_NAME.'canvas' : $canvasType;
return parent::existCanvas($projectId, $canvasTitle, $type);
}
/**
* copyCanvas - thin override defaulting the canvas type to "goalcanvas".
*
* Keeps the parent's optional type parameter so the signature stays compatible.
*
* @param int $projectId Project identifier
* @param int $canvasId Source canvas identifier
* @param int $authorId Author identifier
* @param string $canvasTitle New canvas title
* @param string|null $canvasType Canvas type override (defaults to "goalcanvas")
* @return int New canvas identifier
*/
public function copyCanvas(int $projectId, int $canvasId, int $authorId, string $canvasTitle, $canvasType = null): int
{
$type = ($canvasType === null || $canvasType === '') ? static::CANVAS_NAME.'canvas' : $canvasType;
return parent::copyCanvas($projectId, $canvasId, $authorId, $canvasTitle, $type);
}
/**
* getNumberOfBoards - thin override defaulting the canvas type to "goalcanvas".
*
* Keeps the parent's optional type parameter so the signature stays compatible.
*
* @param int|null $projectId Project identifier
* @param string|null $canvasType Canvas type override (defaults to "goalcanvas")
*/
public function getNumberOfBoards($projectId = null, $canvasType = null): mixed
{
$type = ($canvasType === null || $canvasType === '') ? static::CANVAS_NAME.'canvas' : $canvasType;
return parent::getNumberOfBoards($projectId !== null ? (int) $projectId : null, $type);
}
/**
* getNumberOfCanvasItems - thin override defaulting the canvas type to "goalcanvas".
*
* Keeps the parent's optional type parameter so the signature stays compatible.
*
* @param int|null $projectId Project identifier
* @param string|null $canvasType Canvas type override (defaults to "goalcanvas")
*/
public function getNumberOfCanvasItems($projectId = null, $canvasType = null): mixed
{
$type = ($canvasType === null || $canvasType === '') ? static::CANVAS_NAME.'canvas' : $canvasType;
return parent::getNumberOfCanvasItems($projectId !== null ? (int) $projectId : null, $type);
}
/**
* Gets all goals related to a milestone
*/
public function getGoalsByMilestone(int $milestoneId): false|array
{
// No valid milestone → no goals; bail before any lookup so a 0/negative
// id can't match empty/blank legacy milestoneId values.
if ($milestoneId <= 0) {
return [];
}
// Reverse lookup via the tracked_by edge graph, now returning a goal
// linked to this milestone by ANY of its (possibly many) edges. Also
// union in goals still linked only via the legacy milestoneId column —
// some writers (e.g. the onboarding Helper) set the column without
// syncing an edge, so an edge-only read would miss them.
$goalIds = $this->getGoalIdsForMilestone($milestoneId);
$columnLinked = $this->dbConnection->table('zp_canvas_items')
->where('box', 'goal')
// milestoneId is a varchar column — compare as a string so the
// predicate is portable (a varchar = int comparison errors on
// PostgreSQL).
->where('milestoneId', (string) $milestoneId)
->pluck('id')
->map(static fn ($id) => (int) $id)
->all();
$goalIds = array_values(array_unique([...$goalIds, ...$columnLinked]));
if ($goalIds === []) {
return [];
}
// Scope to the milestone's OWN project. Goal↔milestone links are
// same-project, but the legacy milestoneId column (and the 30524
// backfill) can carry stale cross-project ids — filter the candidate
// goals to goalcanvas boards in the milestone's project so a foreign
// goal can never leak into this reverse lookup.
$milestoneProjectId = $this->dbConnection->table('zp_tickets')
->where('id', $milestoneId)
->where('type', 'milestone')
->where('status', '<>', -1) // ignore soft-deleted milestones
->value('projectId');
if ($milestoneProjectId === null) {
return [];
}
$goalIds = $this->dbConnection->table('zp_canvas_items as ci')
->join('zp_canvas as cb', 'ci.canvasId', '=', 'cb.id')
->whereIn('ci.id', $goalIds)
->where('ci.box', 'goal')
->where('cb.type', 'goalcanvas')
->where('cb.projectId', (int) $milestoneProjectId)
->pluck('ci.id')
->map(static fn ($id) => (int) $id)
->all();
if ($goalIds === []) {
return [];
}
$results = $this->dbConnection->table('zp_canvas_items')
->select(
'id',
'description',
'title',
'assumptions',
'data',
'conclusion',
'box',
'author',
'created',
'modified',
'canvasId',
'sortindex',
'status',
'relates',
'milestoneId',
'kpi',
'data1',
'data2',
'data3',
'data4',
'data5',
'startDate',
'endDate',
'setting',
'metricType',
'startValue',
'currentValue',
'endValue',
'impact',
'effort',
'probability',
'action',
'assignedTo',
'parent',
'tags'
)
->where('box', 'goal')
->whereIn('id', $goalIds)
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
// ─── Goal↔milestone edges (tracked_by on zp_entity_relationship) ──────
//
// Many-to-many replacement for the legacy single milestoneId column.
// Mirrors the collaborator relationship pattern (Tickets::addCollaborators
// / getCollaborators / removeCollaborators). Direction convention:
// entityA = goal (GoalItem), entityB = milestone (Ticket).
/**
* Link a milestone to a goal (idempotent — skips an existing edge).
*
* The check-then-insert runs inside a transaction with a locking read so
* concurrent link requests for the same pair can't both insert. zp_entity_
* relationship is a shared table with no composite unique constraint (other
* relationship types may hold legitimate duplicates), so idempotence is
* enforced here rather than by the schema; the edge readers also dedupe.
*
* @param int $userId Author of the link; 0/unknown is stored as NULL so
* it is never read back as a real user id (matches
* migration 30524's backfill convention).
*
* @api
*/
public function addGoalMilestoneLink(int $goalId, int $milestoneId, int $userId): bool
{
if ($goalId <= 0 || $milestoneId <= 0) {
return false;
}
// Fail closed: only link a real milestone that lives in the SAME project
// as the goal. Resolve the goal's project via its canvas, and the
// target's project + type. Rejecting a forged/foreign or non-milestone
// id here (the shared write chokepoint for every caller) stops a link
// from surfacing another project's milestone headline on the goal chips.
$goalProjectId = $this->dbConnection->table('zp_canvas_items as ci')
->join('zp_canvas as cb', 'ci.canvasId', '=', 'cb.id')
->where('ci.id', $goalId)
->where('ci.box', 'goal')
->where('cb.type', 'goalcanvas')
->value('cb.projectId');
$milestone = $this->dbConnection->table('zp_tickets')
->where('id', $milestoneId)
->where('type', 'milestone')
->where('status', '<>', -1) // exclude deleted (Leantime's soft-delete sentinel)
->first(['projectId']);
if ($goalProjectId === null || $milestone === null
|| (int) $milestone->projectId !== (int) $goalProjectId) {
return false;
}
return $this->dbConnection->transaction(function () use ($goalId, $milestoneId, $userId): bool {
$exists = $this->dbConnection->table('zp_entity_relationship')
->where('entityA', $goalId)
->where('entityAType', 'GoalItem')
->where('entityB', $milestoneId)
->where('entityBType', 'Ticket')
->where('relationship', EntityRelationshipEnum::TrackedBy->value)
->lockForUpdate()
->exists();
if ($exists) {
return true;
}
$this->dbConnection->table('zp_entity_relationship')->insert([
'entityA' => $goalId,
'entityAType' => 'GoalItem',
'entityB' => $milestoneId,
'entityBType' => 'Ticket',
'relationship' => EntityRelationshipEnum::TrackedBy->value,
'createdOn' => now(),
'createdBy' => $userId > 0 ? $userId : null,
]);
return true;
});
}
/**
* Resolve a milestone's project id. Null when the id is not a live
* milestone-type ticket — the read-auth counterpart of the same-project
* validation in addGoalMilestoneLink().
*/
public function getMilestoneProjectId(int $milestoneId): ?int
{
if ($milestoneId <= 0) {
return null;
}
$projectId = $this->dbConnection->table('zp_tickets')
->where('id', $milestoneId)
->where('type', 'milestone')
->where('status', '<>', -1)
->value('projectId');
return $projectId === null ? null : (int) $projectId;
}
/**
* Remove a single goal↔milestone link: delete the tracked_by edge and clear
* the legacy milestoneId column if it still points at this milestone.
*
* @param int $goalId Goal canvas-item id (entityA of the edge).
* @param int $milestoneId Milestone ticket id (entityB of the edge).
* @return bool True if an edge row was deleted OR the legacy column was cleared.
*
* @api
*/
public function removeGoalMilestoneLink(int $goalId, int $milestoneId): bool
{
$deleted = $this->dbConnection->table('zp_entity_relationship')
->where('entityA', $goalId)
->where('entityAType', 'GoalItem')
->where('entityB', $milestoneId)
->where('entityBType', 'Ticket')
->where('relationship', EntityRelationshipEnum::TrackedBy->value)
->delete() > 0;
// Keep the legacy milestoneId column consistent with the edges: if it
// still points at the milestone we just unlinked, clear it. Otherwise
// the column-union in getGoalsByMilestone() would re-surface this goal
// after an explicit unlink (edge removed but column stale).
$columnCleared = $this->dbConnection->table('zp_canvas_items')
->where('id', $goalId)
->where('box', 'goal')
->where('milestoneId', (string) $milestoneId)
->update(['milestoneId' => '']) > 0;
// Report success if EITHER representation was pointing at this
// milestone — a stale legacy column with no edge is still a real
// unlink, so returning false there would mislead callers.
return $deleted || $columnCleared;
}
/**
* Remove every milestone link from a goal (goal delete / full reset): delete
* all tracked_by edges for the goal and clear its legacy milestoneId column.
*
* @param int $goalId Goal canvas-item id (entityA of the edges).
* @return bool True if any edge row was deleted OR the legacy column was cleared.
*/
public function removeAllGoalMilestoneLinks(int $goalId): bool
{
$deleted = $this->dbConnection->table('zp_entity_relationship')
->where('entityA', $goalId)
->where('entityAType', 'GoalItem')
->where('entityBType', 'Ticket')
->where('relationship', EntityRelationshipEnum::TrackedBy->value)
->delete() > 0;
// All of the goal's links are gone, so clear the legacy milestoneId
// column too — otherwise the column-union in getGoalsByMilestone() would
// re-surface this goal after a full reset (edges gone but column stale).
$columnCleared = $this->dbConnection->table('zp_canvas_items')
->where('id', $goalId)
->where('box', 'goal')
->update(['milestoneId' => '']) > 0;
// Report success if EITHER representation held a link — a stale legacy
// column with no edge is still a real reset to clear.
return $deleted || $columnCleared;
}
/**
* Milestone ids this goal is tracked by.
*
* @return array<int, int>
*
* @api
*/
public function getMilestoneIdsForGoal(int $goalId): array
{
return $this->dbConnection->table('zp_entity_relationship')
->where('entityA', $goalId)
->where('entityAType', 'GoalItem')
->where('entityBType', 'Ticket')
->where('relationship', EntityRelationshipEnum::TrackedBy->value)
->pluck('entityB')
->map(fn ($id) => (int) $id)
->unique()
->values()
->all();
}
/**
* Goal ids tracked_by the given milestone — the edge-based replacement for
* the old `WHERE milestoneId = ?` reverse lookup.
*
* @return array<int, int>
*
* @api
*/
public function getGoalIdsForMilestone(int $milestoneId): array
{
return $this->dbConnection->table('zp_entity_relationship')
->where('entityAType', 'GoalItem')
->where('entityB', $milestoneId)
->where('entityBType', 'Ticket')
->where('relationship', EntityRelationshipEnum::TrackedBy->value)
->pluck('entityA')
->map(fn ($id) => (int) $id)
->unique()
->values()
->all();
}
/**
* getSingleCanvas - returns a single goal canvas board row.
*
* Keeps the parent's optional type parameter so the signature stays compatible;
* the goal query is always scoped to the goal canvas type.
*
* @param int $canvasId Canvas board identifier
* @param string|null $canvasType Canvas type override (unused; query is goal-scoped)
* @return false|array<string, mixed>
*/
public function getSingleCanvas($canvasId, $canvasType = null): false|array
{
$result = $this->dbConnection->table('zp_canvas')
->select(
'zp_canvas.id',
'zp_canvas.title',
'zp_canvas.author',
'zp_canvas.description',
'zp_canvas.created',
'zp_canvas.projectId',
't1.firstname AS authorFirstname',
't1.lastname AS authorLastname'
)
->leftJoin('zp_user AS t1', 'zp_canvas.author', '=', 't1.id')
->where('type', static::CANVAS_NAME.'canvas')
->where('zp_canvas.id', $canvasId)
->orderBy('zp_canvas.title')
->orderBy('zp_canvas.created')
->first();
return $result ? (array) $result : false;
}
/**
* Gets all goals related to a milestone
*/
public function getAllAccountGoals(?int $projectId, ?int $boardId): false|array
{
$userId = session('userdata.id') ?? -1;
$clientId = session('userdata.clientId') ?? -1;
$requesterRole = session()->exists('userdata') ? session('userdata.role') : -1;
$query = $this->dbConnection->table('zp_canvas_items')
->select(
'zp_canvas_items.id',
'zp_canvas_items.description',
'zp_canvas_items.title',
'zp_canvas_items.assumptions',
'zp_canvas_items.data',
'zp_canvas_items.conclusion',
'zp_canvas_items.box',
'zp_canvas_items.author',
'zp_canvas_items.created',
'zp_canvas_items.modified',
'zp_canvas_items.canvasId',
'zp_canvas_items.sortindex',
'zp_canvas_items.status',
'zp_canvas_items.relates',
'zp_canvas_items.milestoneId',
'zp_canvas_items.kpi',
'zp_canvas_items.data1',
'zp_canvas_items.data2',
'zp_canvas_items.data3',
'zp_canvas_items.data4',
'zp_canvas_items.data5',
'zp_canvas_items.startDate',
'zp_canvas_items.endDate',
'zp_canvas_items.setting',
'zp_canvas_items.metricType',
'zp_canvas_items.startValue',
'zp_canvas_items.currentValue',
'zp_canvas_items.endValue',
'zp_canvas_items.impact',
'zp_canvas_items.effort',
'zp_canvas_items.probability',
'zp_canvas_items.action',
'zp_canvas_items.assignedTo',
'zp_canvas_items.parent',
'zp_canvas_items.tags',
'zp_canvas.projectId'
)
->leftJoin('zp_canvas', 'zp_canvas_items.canvasId', '=', 'zp_canvas.id')
->leftJoin('zp_projects', 'zp_canvas.projectId', '=', 'zp_projects.id')
->where('zp_canvas_items.box', 'goal')
->where(function ($q) use ($userId, $clientId, $requesterRole) {
$q->whereIn('zp_canvas.projectId', function ($subquery) use ($userId) {
$subquery->select('projectId')
->from('zp_relationuserproject')
->where('userId', $userId);
})
->orWhere('zp_projects.psettings', 'all')
->orWhere(function ($q2) use ($clientId) {
$q2->where('zp_projects.psettings', 'clients')
->where('zp_projects.clientId', $clientId);
});
// Admin and manager roles have access to all projects
if (in_array($requesterRole, ['admin', 'manager'])) {
$q->orWhereRaw('1=1');
}
});
if (isset($projectId) && $projectId > 0) {
$query->where('zp_canvas.projectId', $projectId);
}
if (isset($boardId) && $boardId > 0) {
$query->where('zp_canvas.id', $boardId);
}
$results = $query->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
public function createGoal(array $values): false|string
{
$id = $this->dbConnection->table('zp_canvas_items')->insertGetId([
'description' => $values['description'] ?? '',
'title' => $values['title'] ?? '',
'assumptions' => $values['assumptions'] ?? '',
'data' => $values['data'] ?? '',
'conclusion' => $values['conclusion'] ?? '',
'box' => $values['box'],
'author' => $values['author'],
'created' => now(),
'modified' => now(),
'canvasId' => $values['canvasId'],
'status' => $values['status'] ?? '',
'relates' => $values['relates'] ?? '',
'milestoneId' => $values['milestoneId'] ?? '',
'kpi' => $values['kpi'] ?? '',
'data1' => $values['data1'] ?? '',
'startDate' => $values['startDate'] ?? '',
'endDate' => $values['endDate'] ?? '',
'setting' => $values['setting'] ?? '',
'metricType' => $values['metricType'] ?? '',
'impact' => $values['impact'] ?? '',
'effort' => $values['effort'] ?? '',
'probability' => $values['probability'] ?? '',
'action' => $values['action'] ?? '',
'assignedTo' => $values['assignedTo'] ?? '',
'startValue' => $values['startValue'] ?? '',
'currentValue' => $values['currentValue'] ?? '',
'endValue' => $values['endValue'] ?? '',
'parent' => $values['parent'] ?? '',
'tags' => $values['tags'] ?? '',
]);
$this->recordGoalValueHistory((int) $id, $values['currentValue'] ?? null);
return (string) $id;
}
/**
* getCanvasItemsByKPI - goal-specific KPI hierarchy query (not part of Blueprints).
*
* @param int $id KPI (parent) canvas item identifier
* @return false|array<int, array<string, mixed>>
*/
public function getCanvasItemsByKPI($id): false|array
{
$results = $this->connection->table('zp_canvas_items')
->select([
'zp_canvas_items.id',
'zp_canvas_items.title',
'zp_canvas_items.kpi',
'zp_canvas_items.startDate',
'zp_canvas_items.endDate',
'zp_canvas_items.setting',
'zp_canvas_items.metricType',
'zp_canvas_items.startValue',
'zp_canvas_items.currentValue',
'zp_canvas_items.endValue',
'zp_canvas_items.canvasId',
'zp_canvas.title as boardTitle',
'zp_canvas.projectId as projectId',
'zp_projects.name as projectName',
'childrenLvl1.id as childId',
'childrenLvl1.title as childTitle',
'childrenLvl1.kpi as childKpi',
'childrenLvl1.startDate as childStartDate',
'childrenLvl1.endDate as childEndDate',
'childrenLvl1.setting as childSetting',
'childrenLvl1.metricType as childMetricType',
'childrenLvl1.startValue as childStartValue',
'childrenLvl1.currentValue as childCurrentValue',
'childrenLvl1.endValue as childEndValue',
'childrenLvl1.canvasId as childCanvasId',
'childrenLvl1Board.title as childBoardTitle',
'childrenLvl1Project.name as childProjectName',
])
->leftJoin('zp_canvas', 'zp_canvas_items.canvasId', '=', 'zp_canvas.id')
->leftJoin('zp_projects', 'zp_canvas.projectId', '=', 'zp_projects.id')
->leftJoin('zp_canvas_items as childrenLvl1', 'childrenLvl1.kpi', '=', 'zp_canvas_items.id')
->leftJoin('zp_canvas as childrenLvl1Board', 'childrenLvl1.canvasId', '=', 'childrenLvl1Board.id')
->leftJoin('zp_projects as childrenLvl1Project', 'childrenLvl1Board.projectId', '=', 'childrenLvl1Project.id')
->where('zp_canvas_items.box', 'goal')
->where('zp_canvas_items.kpi', $id)
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
/**
* getAllAvailableParents - goal-specific parent lookup (not part of Blueprints).
*
* @param int $projectId Project identifier
* @return false|array<int, array<string, mixed>>
*/
public function getAllAvailableParents($projectId): false|array
{
$results = $this->connection->table('zp_canvas_items')
->select([
'zp_canvas_items.id',
'zp_canvas_items.description',
'zp_canvas_items.title',
'zp_canvas_items.assumptions',
'zp_canvas_items.data',
'zp_canvas_items.conclusion',
'zp_canvas_items.box',
'zp_canvas_items.author',
'zp_canvas_items.created',
'zp_canvas_items.modified',
'zp_canvas_items.canvasId',
'zp_canvas_items.sortindex',
'zp_canvas_items.status',
'zp_canvas_items.relates',
'zp_canvas_items.milestoneId',
'zp_canvas_items.kpi',
'zp_canvas_items.data1',
'zp_canvas_items.data2',
'zp_canvas_items.data3',
'zp_canvas_items.data4',
'zp_canvas_items.data5',
'zp_canvas_items.startDate',
'zp_canvas_items.endDate',
'zp_canvas_items.setting',
'zp_canvas_items.metricType',
'zp_canvas_items.startValue',
'zp_canvas_items.currentValue',
'zp_canvas_items.endValue',
'zp_canvas_items.impact',
'zp_canvas_items.effort',
'zp_canvas_items.probability',
'zp_canvas_items.action',
'zp_canvas_items.assignedTo',
'zp_canvas_items.parent',
'zp_canvas_items.tags',
'board.title as boardTitle',
'parentKPI.description as parentKPIDescription',
'parentGoal.description as parentGoalDescription',
't1.firstname as authorFirstname',
't1.lastname as authorLastname',
'milestone.headline as milestoneHeadline',
'milestone.editTo as milestoneEditTo',
])
->leftJoin('zp_canvas as board', 'board.id', '=', 'zp_canvas_items.canvasId')
->leftJoin('zp_canvas_items as parentKPI', 'zp_canvas_items.kpi', '=', 'parentKPI.id')
->leftJoin('zp_canvas_items as parentGoal', 'zp_canvas_items.parent', '=', 'parentGoal.id')
->leftJoin('zp_tickets as milestone', function ($join) {
$join->on('zp_canvas_items.milestoneId', '=', $this->connection->raw($this->dbHelper->castAs($this->dbHelper->wrapColumn('milestone.id'), 'text')));
})
->leftJoin('zp_user as t1', 'zp_canvas_items.author', '=', 't1.id')
->where('board.projectId', $projectId)
->groupBy(['id', 'board.id', 'board.title', 'parentKPI.description', 'parentGoal.description', 't1.firstname', 't1.lastname', 'milestone.headline', 'milestone.editTo'])
->orderBy('board.id')
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
/**
* getAllAvailableKPIs - goal-specific KPI lookup across parent projects (not part of Blueprints).
*
* @param int $projectId Project identifier
* @return false|array<int, array<string, mixed>>
*/
public function getAllAvailableKPIs($projectId): false|array
{
// First, get parent project IDs (cross-database compatible approach)
$parentProjectIds = [];
$parentProject = $this->connection->table('zp_projects')
->select('parent')
->where('id', $projectId)
->first();
if ($parentProject && $parentProject->parent) {
$parentProjectIds[] = $parentProject->parent;
// Check for grandparent
$grandparent = $this->connection->table('zp_projects')
->select('parent')
->where('id', $parentProject->parent)
->first();
if ($grandparent && $grandparent->parent) {
$parentProjectIds[] = $grandparent->parent;
}
}
// If no parent projects found, return empty array
if (empty($parentProjectIds)) {
return [];
}
// Now query canvas items from parent projects
$results = $this->connection->table('zp_canvas_items')
->select([
'zp_canvas_items.id',
'zp_canvas_items.description',
'project.name as projectName',
'zp_canvas.title as boardTitle',
])
->leftJoin('zp_canvas', 'zp_canvas.id', '=', 'zp_canvas_items.canvasId')
->leftJoin('zp_projects as project', 'zp_canvas.projectId', '=', 'project.id')
->whereIn('zp_canvas.projectId', $parentProjectIds)
->where(function ($query) {
$query->where('zp_canvas_items.setting', 'linkAndReport')
->orWhere('zp_canvas_items.setting', 'linkonly');
})
->orderBy('zp_canvas.id')
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
/**
* {@inheritDoc}
*
* Goal boards additionally record the initial metric value so KPI trends start at creation.
*/
public function addCanvasItem(array $values): false|string
{
$id = parent::addCanvasItem($values);
if ($id !== false && ($values['box'] ?? '') === 'goal') {
$this->recordGoalValueHistory((int) $id, $values['currentValue'] ?? null);
}
return $id;
}
/**
* {@inheritDoc}
*
* Goal boards additionally record metric value changes to zp_goal_history,
* and preserve the legacy milestoneId column on payloads that omit it.
*/
public function editCanvasItem(array $values): void
{
$itemId = (int) ($values['itemId'] ?? $values['id'] ?? 0);
$previousValue = $this->getCurrentGoalValue($itemId);
// Blueprints::editCanvasItem writes milestoneId unconditionally with
// `?? ''`, so a payload that simply omits the key (the edges-driven
// goal dialog does, by design) would blank the goal's legacy column on
// every save and break the dual-write invariant for column readers.
// Absent key = "don't touch", matching the service's edge-sync guard.
if (! array_key_exists('milestoneId', $values) && $itemId > 0) {
$values['milestoneId'] = (string) ($this->dbConnection->table('zp_canvas_items')
->where('id', $itemId)
->value('milestoneId') ?? '');
}
parent::editCanvasItem($values);
if (
$previousValue !== false
&& array_key_exists('currentValue', $values)
&& is_numeric($values['currentValue'])
&& ($previousValue === null || (float) $values['currentValue'] !== $previousValue)
) {
$this->recordGoalValueHistory($itemId, $values['currentValue']);
}
}
/**
* {@inheritDoc}
*
* Goal boards additionally record metric value changes to zp_goal_history.
*/
public function patchCanvasItem(int $id, array $params): bool
{
$previousValue = array_key_exists('currentValue', $params) ? $this->getCurrentGoalValue($id) : false;
$result = parent::patchCanvasItem($id, $params);
if (
$result
&& $previousValue !== false
&& is_numeric($params['currentValue'])
&& ($previousValue === null || (float) $params['currentValue'] !== $previousValue)
) {
$this->recordGoalValueHistory($id, $params['currentValue']);
}
return $result;
}
/**
* Daily safety net behind the write-path hooks: records a history row for every goal whose
* current value differs from its latest recorded value (covers writes that bypassed this
* repository). Returns the number of rows written.
*/
public function snapshotGoalValues(): int
{
$goals = $this->dbConnection->table('zp_canvas_items')
->select(['zp_canvas_items.id', 'zp_canvas_items.currentValue'])
->join('zp_canvas', 'zp_canvas_items.canvasId', '=', 'zp_canvas.id')
->where('zp_canvas.type', '=', static::CANVAS_NAME.'canvas')
->where('zp_canvas_items.box', '=', 'goal')
->get();
$latestValues = [];
$latestDates = $this->dbConnection->table('zp_goal_history')
->selectRaw('itemId, MAX(dateRecorded) as maxDate')
->groupBy('itemId');
$latestRows = $this->dbConnection->table('zp_goal_history')
->select(['zp_goal_history.itemId', 'zp_goal_history.value'])
->joinSub($latestDates, 'latest', function ($join) {
$join->on('zp_goal_history.itemId', '=', 'latest.itemId')
->on('zp_goal_history.dateRecorded', '=', 'latest.maxDate');
})
->get();
foreach ($latestRows as $row) {
$latestValues[(int) $row->itemId] = (float) $row->value;
}
$inserts = [];
// History reads normalize to UTC — write explicit UTC, not the app timezone.
$now = dtHelper()->dbNow()->formatDateTimeForDb();
foreach ($goals as $goal) {
// Goals without a numeric value have no trend point to record — casting NULL
// to 0.0 would fabricate history (mirrors recordGoalValueHistory's guard).
if ($goal->currentValue === null || $goal->currentValue === '' || ! is_numeric($goal->currentValue)) {
continue;
}
$currentValue = (float) $goal->currentValue;
if (! array_key_exists((int) $goal->id, $latestValues) || $latestValues[(int) $goal->id] !== $currentValue) {
$inserts[] = [
'itemId' => (int) $goal->id,
'value' => $currentValue,
'userId' => null,
'dateRecorded' => $now,
];
}
}
if ($inserts !== []) {
$this->dbConnection->table('zp_goal_history')->insert($inserts);
}
return count($inserts);
}
/**
* The recorded value of a goal at a point in time — the last snapshot on
* or before {@see $asOf}. Returns null when the goal has no history at or
* before that date.
*
* Periods are a query, never stored: a period's value is whatever the
* last snapshot on or before its end date says. Missing days don't matter
* because the last known value carries — the write path is intentionally
* sparse (only fires when the value changes) and this read is what makes
* that sparseness invisible to callers.
*
* `$asOf` is normalized to UTC before comparison because `zp_goal_history`
* rows are always written in UTC (per the CLAUDE.md db-time convention);
* callers can pass a user-tz DateTime without silently drifting by hours.
*/
public function getGoalValueAt(int $itemId, \DateTimeInterface $asOf): ?float
{
if ($itemId <= 0) {
return null;
}
$asOfUtc = (new \DateTimeImmutable('@'.$asOf->getTimestamp()))
->setTimezone(new \DateTimeZone('UTC'));
$row = $this->dbConnection->table('zp_goal_history')
->select('value')
->where('itemId', $itemId)
->where('dateRecorded', '<=', $asOfUtc->format('Y-m-d H:i:s'))
->orderByDesc('dateRecorded')
->orderByDesc('id')
->first();
return $row === null ? null : (float) $row->value;
}
/**
* The recorded values of a set of goals at a series of dates. Returned as
* `[goalId => [YYYY-MM-DD_HH:MM:SS => ?float]]`, one entry per requested
* `(goalId, date)` pair. Null carries "no snapshot on or before that
* date" through unchanged — callers decide how to render an unmeasured
* period-end (Page 4 "How far we've come" draws it as a ghost bar).
*
* One SELECT per goal (not per goal×date). We fetch every snapshot up to
* the latest requested date once and bucket in PHP — a 20-goal × 12-date
* arc goes from 240 round-trips to 20. The date keys in the returned
* array are formatted in UTC to match the read semantic.
*
* @param int[] $itemIds Goal (canvas item) ids.
* @param array<\DateTimeInterface> $dates Period-end (or arbitrary) dates.
* @return array<int, array<string, ?float>>
*/
public function getGoalValuesAtSeries(array $itemIds, array $dates): array
{
$itemIds = array_values(array_unique(array_map('intval', $itemIds)));
$itemIds = array_values(array_filter($itemIds, static fn (int $id): bool => $id > 0));
if ($itemIds === [] || $dates === []) {
return [];
}
// Normalize dates to UTC and sort ascending — we walk snapshots
// forward per goal and advance the requested-date cursor in lockstep.
$utcTz = new \DateTimeZone('UTC');
$normalizedDates = array_map(
static fn (\DateTimeInterface $d) => (new \DateTimeImmutable('@'.$d->getTimestamp()))->setTimezone($utcTz),
$dates
);
usort($normalizedDates, static fn ($a, $b) => $a <=> $b);
$dateKeys = array_map(static fn ($d) => $d->format('Y-m-d H:i:s'), $normalizedDates);
$latestDate = end($normalizedDates)->format('Y-m-d H:i:s');
// Prime output with nulls in the original (sorted) key order.
$out = [];
foreach ($itemIds as $id) {
$out[$id] = array_fill_keys($dateKeys, null);
}
// One SELECT per goal, capped at the latest requested date. Rows come
// back date-ASC so we can carry-forward the last value through gap
// dates in a single pass.
foreach ($itemIds as $id) {
$rows = $this->dbConnection->table('zp_goal_history')
->select(['value', 'dateRecorded', 'id'])
->where('itemId', $id)
->where('dateRecorded', '<=', $latestDate)
->orderBy('dateRecorded')
->orderBy('id')
->get();
$cursor = 0;
$lastValue = null;
foreach ($rows as $row) {
$rowDate = (string) $row->dateRecorded;
// Advance any date-keys we've now passed, stamping the value
// that was current on-or-before each one.
while ($cursor < count($dateKeys) && $rowDate > $dateKeys[$cursor]) {
$out[$id][$dateKeys[$cursor]] = $lastValue;
$cursor++;
}
$lastValue = (float) $row->value;
}
// Any remaining date-keys are at or after every snapshot — stamp
// the last-known value for each.
while ($cursor < count($dateKeys)) {
$out[$id][$dateKeys[$cursor]] = $lastValue;
$cursor++;
}
}
return $out;
}
/**
* Bulk-insert history rows from an already-parsed set of tuples. Used by
* the CSV backfill command; rows are indistinguishable from ones written
* by {@see recordGoalValueHistory} or {@see snapshotGoalValues} — same
* table, same columns.
*
* NOT `@api`. This is a mass-write repository method with no permission
* check by design — callers (the CSV backfill CLI command) are trusted
* shell-level admin actions. Do NOT expose over JSON-RPC or any user-
* reachable surface without a service wrapper that enforces authz.
*
* Chunks to 500 rows per INSERT to stay under `max_allowed_packet` on
* default MySQL configs, and wraps the whole run in a transaction so a
* mid-import failure rolls back cleanly.
*
* @param array<int, array{itemId: int, value: float, dateRecorded: string, userId?: ?int}> $rows
* @return int Rows written.
*/
public function insertGoalHistoryRows(array $rows): int
{
if ($rows === []) {
return 0;
}
$written = 0;
$this->dbConnection->transaction(function () use ($rows, &$written): void {
foreach (array_chunk($rows, 500) as $chunk) {
$this->dbConnection->table('zp_goal_history')->insert($chunk);
$written += count($chunk);
}
});
return $written;
}
/**
* Given a set of `zp_canvas_items.id` values, return the subset that are
* actually goals (i.e. `box='goal'` on a goal-type canvas). Used by the
* CSV backfill command to reject rows targeting non-goal item ids before
* writing to `zp_goal_history` — an integrity check on write, since the
* history table has no foreign key to enforce this.
*
* @param int[] $itemIds
* @return int[] Sorted, de-duplicated valid goal item ids.
*/
public function filterGoalItemIds(array $itemIds): array
{
$itemIds = array_values(array_unique(array_map('intval', $itemIds)));
$itemIds = array_values(array_filter($itemIds, static fn (int $id): bool => $id > 0));
if ($itemIds === []) {
return [];
}
$rows = $this->dbConnection->table('zp_canvas_items')
->select('zp_canvas_items.id')
->join('zp_canvas', 'zp_canvas_items.canvasId', '=', 'zp_canvas.id')
->where('zp_canvas.type', '=', static::CANVAS_NAME.'canvas')
->where('zp_canvas_items.box', '=', 'goal')
->whereIn('zp_canvas_items.id', $itemIds)
->get();
$valid = array_map(static fn ($r) => (int) $r->id, $rows->all());
sort($valid);
return $valid;
}
/**
* The stored metric value of a goal item, three-state: false when the item isn't a
* goal (or is unknown) — record nothing; null when the goal has no numeric value yet —
* a first value (including an explicit 0) must record; otherwise the float value.
*/
private function getCurrentGoalValue(int $itemId): float|null|false
{
if ($itemId === 0) {
return false;
}
$item = $this->dbConnection->table('zp_canvas_items')
->select(['currentValue', 'box'])
->where('id', $itemId)
->first();
if ($item === null || $item->box !== 'goal') {
return false;
}
if ($item->currentValue === null || $item->currentValue === '' || ! is_numeric($item->currentValue)) {
return null;
}
return (float) $item->currentValue;
}
/**
* Appends a zp_goal_history row so metric changes stay chartable over time.
*/
private function recordGoalValueHistory(int $itemId, mixed $value): void
{
if ($itemId === 0 || $value === null || $value === '' || ! is_numeric($value)) {
return;
}
$this->dbConnection->table('zp_goal_history')->insert([
'itemId' => $itemId,
'value' => (float) $value,
'userId' => session()->exists('userdata') ? (int) session('userdata.id') : null,
// History reads normalize to UTC — write explicit UTC, not the app timezone.
'dateRecorded' => dtHelper()->dbNow()->formatDateTimeForDb(),
]);
}
/**
* Remove every goal link pointing at a milestone — the milestone-deletion
* cascade. Mirror of removeAllGoalMilestoneLinks, keyed on the milestone
* (entityB) side.
*
* @api
*/
public function removeMilestoneFromAllGoals(int $milestoneId): bool
{
$deleted = $this->dbConnection->table('zp_entity_relationship')
->where('entityAType', 'GoalItem')
->where('entityB', $milestoneId)
->where('entityBType', 'Ticket')
->where('relationship', EntityRelationshipEnum::TrackedBy->value)
->delete() > 0;
// Clear the legacy milestoneId column on any goal still pointing at the
// now-detached/deleted milestone, so a stale value can't make a goal
// look still-linked to a milestone whose edge is gone.
$this->dbConnection->table('zp_canvas_items')
->where('box', 'goal')
->where('milestoneId', (string) $milestoneId)
->update(['milestoneId' => '']);
return $deleted;
}
/**
* Batch resolver: project id for many canvas items of the given type in ONE
* query, so callers authorizing a set of items don't run a query per id.
* Returns [itemId => projectId]; ids that don't resolve (missing / wrong
* canvas type) are absent.
*
* @param int[] $itemIds
* @return array<int, int>
*/
public function getCanvasItemProjectIds(array $itemIds, string $canvasType): array
{
$itemIds = array_values(array_unique(array_filter(array_map('intval', $itemIds))));
if ($itemIds === []) {
return [];
}
return $this->dbConnection->table('zp_canvas_items as ci')
->join('zp_canvas as cb', 'ci.canvasId', '=', 'cb.id')
->whereIn('ci.id', $itemIds)
->where('cb.type', $canvasType)
->pluck('cb.projectId', 'ci.id')
->map(static fn ($projectId) => (int) $projectId)
->all();
}
/**
* The milestone chips for a set of goals — each goal's tracked_by
* milestones with name, color, due date, and progress fill. Three queries
* total (edges, milestone details, progress), no N+1. Edges pointing at a
* deleted or non-milestone ticket are dropped.
*
* @param array<int, int> $goalIds
* @return array<int, array<int, array{id: int, headline: string, color: string, projectId: int, editFrom: mixed, editTo: mixed, status: int, statusType: string, percentDone: int}>>
*
* @api
*/
public function getMilestonesForGoals(array $goalIds): array
{
$goalIds = array_values(array_unique(array_filter(array_map('intval', $goalIds))));
if ($goalIds === []) {
return [];
}
$edges = $this->dbConnection->table('zp_entity_relationship')
->whereIn('entityA', $goalIds)
->where('entityAType', 'GoalItem')
->where('entityBType', 'Ticket')
->where('relationship', EntityRelationshipEnum::TrackedBy->value)
->select('entityA', 'entityB')
->get();
if ($edges->isEmpty()) {
return [];
}
$goalToMilestones = [];
$milestoneIdSet = [];
foreach ($edges as $e) {
$goalId = (int) $e->entityA;
$milestoneId = (int) $e->entityB;
// Associative set per goal → O(1) dedup and no array_merge(...) arg
// unpacking (which has a practical limit + extra allocation on large
// sets); a raced duplicate edge can't render as a duplicate chip.
$goalToMilestones[$goalId][$milestoneId] = true;
$milestoneIdSet[$milestoneId] = true;
}
foreach ($goalToMilestones as $goalId => $set) {
$goalToMilestones[$goalId] = array_keys($set);
}
$milestoneIds = array_keys($milestoneIdSet);
$details = [];
$projectIds = [];
foreach (
$this->dbConnection->table('zp_tickets')
->whereIn('id', $milestoneIds)
->where('type', 'milestone')
->select('id', 'headline', 'tags', 'editFrom', 'editTo', 'status', 'projectId')
->get() as $m
) {
$details[(int) $m->id] = [
'id' => (int) $m->id,
'headline' => (string) $m->headline,
'color' => $this->safeChipColor($m->tags),
'editFrom' => $m->editFrom,
'editTo' => $m->editTo,
'status' => (int) $m->status,
'projectId' => (int) $m->projectId,
];
$projectIds[(int) $m->projectId] = true;
}
// Resolve each milestone's statusType (NEW/INPROGRESS/DONE) from its
// project's status labels — cached per project (usually just one).
$statusTypeByProject = [];
foreach (array_keys($projectIds) as $pid) {
$map = [];
foreach ($this->ticketRepository->getStateLabels($pid) as $sid => $label) {
$map[(int) $sid] = (string) ($label['statusType'] ?? 'NEW');
}
$statusTypeByProject[$pid] = $map;
}
$progress = $this->getMilestoneProgressForIds(array_keys($details));
// Chip order: in-progress -> not-started -> done, then due date asc.
$rank = ['INPROGRESS' => 0, 'NEW' => 1, 'DONE' => 2];
$result = [];
foreach ($goalToMilestones as $goalId => $mids) {
$chips = [];
foreach ($mids as $mid) {
if (! isset($details[$mid])) {
continue;
}
$d = $details[$mid];
$chips[] = [
'id' => $d['id'],
'headline' => $d['headline'],
'color' => $d['color'],
'projectId' => $d['projectId'],
'editFrom' => $d['editFrom'],
'editTo' => $d['editTo'],
'status' => $d['status'],
'statusType' => $statusTypeByProject[$d['projectId']][$d['status']] ?? 'NEW',
'percentDone' => $progress[$mid] ?? 0,
];
}
usort($chips, function ($a, $b) use ($rank) {
$ra = $rank[$a['statusType']] ?? 1;
$rb = $rank[$b['statusType']] ?? 1;
if ($ra !== $rb) {
return $ra <=> $rb;
}
// Treat missing AND the zero-date sentinel ('0000-00-00 …') as
// "no due date" so those chips sort last, not first.
$noDue = static fn ($v): bool => $v === null || $v === '' || str_starts_with((string) $v, '0000-00-00');
$da = $noDue($a['editTo']) ? '9999-12-31' : (string) $a['editTo'];
$db = $noDue($b['editTo']) ? '9999-12-31' : (string) $b['editTo'];
return strcmp($da, $db);
});
if ($chips !== []) {
$result[$goalId] = $chips;
}
}
return $result;
}
/**
* Normalize a milestone's `tags` color to a value safe to interpolate into
* an inline `style` attribute. `tags` is user-controlled, so only a hex
* color (#rgb / #rrggbb) or a CSS custom-property reference (var(--x)) is
* allowed through; anything else falls back to the neutral grey token.
*/
private function safeChipColor(?string $tag): string
{
$tag = trim((string) $tag);
if (
preg_match('/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $tag) === 1
|| preg_match('/^var\(--[A-Za-z0-9_-]+\)$/', $tag) === 1
) {
return $tag;
}
return 'var(--grey)';
}
/**
* Per-milestone progress — the same storypoint-weighted "done" ratio the
* single-milestone goal card used, batched across many milestone ids
* (GROUP BY) so a goal shows N progress fills without N queries. Milestones
* with no child tickets resolve to 0.
*
* @param array<int, int> $milestoneIds
* @return array<int, int> milestoneId => percent (0-100)
*/
public function getMilestoneProgressForIds(array $milestoneIds): array
{
$milestoneIds = array_values(array_unique(array_filter(array_map('intval', $milestoneIds))));
if ($milestoneIds === []) {
return [];
}
// A milestone's child tickets live in the milestone's OWN project,
// whose status labels define "done" — so resolve the DONE set per
// project, not from session('currentProject') (which is absent or a
// different project for JSON-RPC / mobile / cross-project reads).
$projectByMilestone = $this->dbConnection->table('zp_tickets')
->whereIn('id', $milestoneIds)
->pluck('projectId', 'id');
$milestonesByProject = [];
foreach ($projectByMilestone as $mid => $pid) {
$milestonesByProject[(int) $pid][] = (int) $mid;
}
$sp = $this->dbHelper->wrapColumn('storypoints');
$st = $this->dbHelper->wrapColumn('status');
$id = $this->dbHelper->wrapColumn('id');
$progress = [];
foreach ($milestonesByProject as $projectId => $projectMilestoneIds) {
$statusGroups = $this->ticketRepository->getStatusListGroupedByType($projectId);
$rows = $this->dbConnection->table('zp_tickets')
->select('milestoneid')
->selectRaw('ROUND(
CASE WHEN COUNT('.$id.') > 0 THEN (
SUM(CASE WHEN '.$st.' '.$statusGroups['DONE'].' THEN CASE WHEN COALESCE('.$sp.', 0) = 0 THEN 3 ELSE '.$sp.' END ELSE 0 END) /
SUM(CASE WHEN COALESCE('.$sp.', 0) = 0 THEN 3 ELSE '.$sp.' END)
) * 100 ELSE 0 END
) AS '.$this->dbHelper->wrapColumn('percentDone'))
->whereIn('milestoneid', $projectMilestoneIds)
->where('type', '<>', 'milestone')
->groupBy('milestoneid')
->get();
foreach ($rows as $r) {
$progress[(int) $r->milestoneid] = (int) $r->percentDone;
}
}
foreach ($milestoneIds as $mid) {
$progress[$mid] ??= 0;
}
return $progress;
}
}