Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions app/Domain/Tickets/Repositories/Tickets.php
Original file line number Diff line number Diff line change
Expand Up @@ -1884,6 +1884,57 @@ public function getStatusChangeEvents(array $ticketIds, string $fromDate, string
return $events;
}

/**
* Distinct ticket ids the given user COMMENTED on within [from, to]
* (inclusive, 'Y-m-d'). Access-agnostic on its own — callers must constrain
* the result to an access-scoped set (e.g. the user's accessible projects)
* before returning anything, so this never widens visibility. Used by
* getMyCommentedTicketsForRange for Progress "Supported".
*/
public function getTicketIdsCommentedByUser(int $userId, string $fromDate, string $toDate): array
{
return $this->connection->table('zp_comment')
->where('module', 'ticket')
->where('userId', $userId)
->whereBetween('date', [$fromDate.' 00:00:00', $toDate.' 23:59:59'])
->distinct()
->pluck('moduleId')
->map(fn ($id) => (int) $id)
->all();
Comment on lines +1914 to +1926
}

/**
* Ticket rows for the given ids, constrained to the given projects (the
* caller's access boundary). One row per ticket with the fields mobile
* user-ticket consumers expect (id, headline, projectId, projectName,
* editorId, userId, status, dateToFinish). Returns raw rows — no resolved
* statusLabel/statusClass. Used by getMyCommentedTicketsForRange.
*/
public function getTicketsByIdsWithinProjects(array $ticketIds, array $projectIds): array
{
if (empty($ticketIds) || empty($projectIds)) {
return [];
}

$rows = $this->connection->table('zp_tickets')
->leftJoin('zp_projects', 'zp_tickets.projectId', '=', 'zp_projects.id')
->select(
'zp_tickets.id',
'zp_tickets.headline',
'zp_tickets.projectId',
'zp_tickets.editorId',
'zp_tickets.userId',
'zp_tickets.status',
'zp_tickets.dateToFinish',
'zp_projects.name as projectName',
)
->whereIn('zp_tickets.id', $ticketIds)
->whereIn('zp_tickets.projectId', $projectIds)
->get();
Comment on lines +1942 to +1963
Comment on lines +1960 to +1963

return array_map(fn ($row) => (array) $row, $rows->all());
}

/**
* Get all tasks (and optionally subtasks) that belong to a milestone
/**
Expand Down
70 changes: 70 additions & 0 deletions app/Domain/Tickets/Services/Tickets.php
Original file line number Diff line number Diff line change
Expand Up @@ -2457,6 +2457,76 @@ public function getMyClosedTicketsForDate(?int $userId = null, ?string $date = n
return array_values($closed);
}

/**
* @api
*
* Tickets the user COMMENTED on within [$from, $to] that they do NOT own
* (they're not the assignee/editor) — i.e. work they supported by weighing
* in on someone else's arc. Powers the mobile Progress "Supported" section
Comment on lines +2516 to +2518
* (presence counts as much as production).
*
* Access safety: commented ticket ids are constrained to the PROJECTS the
* user can access (getProjectsUserHasAccessTo) — NOT to tickets they edit
* or collaborate on. Commenting on a ticket rarely makes you its editor or
* a collaborator, so scoping by those (as an earlier version did via
* simpleTicketQuery) silently dropped most supported work — it collapsed to
* "commented AND collaborator" and could return empty even when comments
Comment on lines +2521 to +2526
* exist. Project access is the correct, still-safe boundary: a historical
* comment can't surface a ticket in a project the user no longer sees. The
* ownership filter then drops tickets the user is the editor of — those are
* "your work," not support. Defaults either bound to today.
*
* @return array<int, array<string, mixed>> raw ticket rows (id, headline,
* projectName, editorId, status, …). NOTE: these do NOT carry the resolved
* statusLabel/statusClass/statusType that getAll*UserTickets attach.
*/
#[RequiresPermission(TicketsPermissions::VIEW)]
public function getMyCommentedTicketsForRange(?int $userId = null, ?string $from = null, ?string $to = null): array
{
Comment on lines +2542 to +2544
$userId = $userId ?? (int) session('userdata.id');
if ($userId === 0) {
return [];
}
$from = $from ?: date('Y-m-d');
$to = $to ?: date('Y-m-d');
if ($from > $to) {
[$from, $to] = [$to, $from];
}
Comment on lines +2542 to +2560

$commentedIds = $this->ticketRepository->getTicketIdsCommentedByUser($userId, $from, $to);
if (empty($commentedIds)) {
return [];
}

// Scope to the projects the user can see — the correct access boundary
// for "tickets I commented on" (you comment on others' work without
// being its editor/collaborator, so filtering by those would drop it).
$projects = $this->projectService->getProjectsUserHasAccessTo($userId);
if (! is_array($projects) || empty($projects)) {
return [];
}
$projectIds = array_values(array_filter(array_map(
fn ($p) => (int) ($p['id'] ?? 0),
$projects
)));
if (empty($projectIds)) {
return [];
}

$tickets = $this->ticketRepository->getTicketsByIdsWithinProjects($commentedIds, $projectIds);

Comment on lines +2567 to +2583
// "Supported", not "yours": drop tickets the user is the editor of.
$out = [];
foreach ($tickets as $ticket) {
if ((string) ($ticket['editorId'] ?? '') === (string) $userId) {
continue;
}
$out[] = $ticket;
}

return array_values($out);
}

/**
* @api
*
Expand Down
75 changes: 75 additions & 0 deletions tests/Unit/app/Domain/Tickets/Services/TicketsServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -544,4 +544,79 @@ public function test_get_all_milestones_unscoped_returns_empty_and_skips_reposit
$this->assertSame([], $result);
$this->assertFalse($called, 'repository should not be queried when criteria are not project-scoped');
}

/**
* Builds a service with a specific ticket repository AND project service —
* the two deps getMyCommentedTicketsForRange exercises.
*/
private function buildServiceWithTicketRepoAndProjectService(
TicketRepository $ticketRepository,
ProjectService $projectService
): TicketsService {
return new TicketsService(
language: $this->make(LanguageCore::class),
ticketRepository: $ticketRepository,
timesheetsRepo: $this->make(TimesheetRepository::class),
settingsRepo: $this->make(SettingRepository::class),
projectService: $projectService,
timesheetService: $this->make(TimesheetService::class),
sprintService: $this->make(SprintService::class),
ticketHistoryRepo: $this->make(TicketHistory::class),
goalcanvasService: $this->make(Goalcanvas::class),
dateTimeHelper: $this->make(DateTimeHelper::class),
commentService: $this->make(CommentService::class),
clientService: $this->make(ClientService::class)
);
}
Comment on lines +645 to +663

/**
* Supported = tickets you commented on within accessible projects, minus
* the ones you're the editor of. Editor-owned tickets are dropped; tickets
* outside the project-scoped fetch never appear.
*/
public function test_commented_tickets_range_excludes_owned_and_scopes_by_projects(): void
{
session(['userdata' => ['id' => 1]]);

$ticketRepository = $this->make(TicketRepository::class, [
'getTicketIdsCommentedByUser' => fn () => [10, 20, 30],
// Project-scoped fetch only returns 10 + 20 (30 is outside access).
'getTicketsByIdsWithinProjects' => fn () => [
['id' => 10, 'headline' => 'A', 'editorId' => '99', 'projectId' => 5, 'projectName' => 'P'],
['id' => 20, 'headline' => 'B', 'editorId' => '1', 'projectId' => 5, 'projectName' => 'P'],
],
]);
$projectService = $this->make(ProjectService::class, [
'getProjectsUserHasAccessTo' => fn () => [['id' => 5], ['id' => 7]],
]);

$service = $this->buildServiceWithTicketRepoAndProjectService($ticketRepository, $projectService);

$result = $service->getMyCommentedTicketsForRange(1, '2026-07-01', '2026-07-07');

// 20 is the user's own (editorId === 1) → excluded; 30 wasn't returned
// by the project-scoped fetch → absent. Only 10 remains.
$this->assertCount(1, $result);
$this->assertEquals(10, $result[0]['id']);
}

/**
* No accessible projects → empty, without ever fetching tickets.
*/
public function test_commented_tickets_range_empty_without_project_access(): void
{
session(['userdata' => ['id' => 1]]);

$ticketRepository = $this->make(TicketRepository::class, [
'getTicketIdsCommentedByUser' => fn () => [10],
'getTicketsByIdsWithinProjects' => fn () => [['id' => 10, 'editorId' => '99']],
]);
$projectService = $this->make(ProjectService::class, [
'getProjectsUserHasAccessTo' => fn () => false,
]);

$service = $this->buildServiceWithTicketRepoAndProjectService($ticketRepository, $projectService);

$this->assertSame([], $service->getMyCommentedTicketsForRange(1, '2026-07-01', '2026-07-07'));
}
Comment on lines +703 to +714
}
Loading