*/ public function visibleTenants(Workspace $workspace, User $user): array { $authorizedTenants = $user->tenants() ->where('tenants.workspace_id', (int) $workspace->getKey()) ->where('tenants.status', 'active') ->orderBy('tenants.name') ->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id']) ->all(); if ($authorizedTenants === []) { return []; } $this->capabilityResolver->primeMemberships( $user, array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $authorizedTenants), ); return array_values(array_filter( $authorizedTenants, fn (Tenant $tenant): bool => $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW), )); } /** * @return Builder */ public function issueQuery( Workspace $workspace, User $user, ?int $tenantId = null, string $reasonFilter = self::FILTER_ALL, bool $applyOrdering = true, ): Builder { $visibleTenants = $this->visibleTenants($workspace, $user); $visibleTenantIds = array_map( static fn (Tenant $tenant): int => (int) $tenant->getKey(), $visibleTenants, ); if ($tenantId !== null && ! in_array($tenantId, $visibleTenantIds, true)) { $visibleTenantIds = []; } elseif ($tenantId !== null) { $visibleTenantIds = [$tenantId]; } $brokenAssignmentExpression = $this->brokenAssignmentExpression(); $lastWorkflowActivityExpression = $this->lastWorkflowActivityExpression(); $staleBindings = [$this->staleThreshold()->toDateTimeString()]; $staleInProgressExpression = $this->staleInProgressExpression($lastWorkflowActivityExpression); $query = Finding::query() ->select('findings.*') ->selectRaw( "case when {$brokenAssignmentExpression} then 1 else 0 end as hygiene_is_broken_assignment", ) ->selectRaw("{$lastWorkflowActivityExpression} as hygiene_last_workflow_activity_at") ->selectRaw( "case when {$staleInProgressExpression} then 1 else 0 end as hygiene_is_stale_in_progress", $staleBindings, ) ->selectRaw( "(case when {$brokenAssignmentExpression} then 1 else 0 end + case when {$staleInProgressExpression} then 1 else 0 end) as hygiene_issue_count", $staleBindings, ) ->with([ 'tenant', 'ownerUser' => static fn ($relation) => $relation->withTrashed(), 'assigneeUser' => static fn ($relation) => $relation->withTrashed(), ]) ->withSubjectDisplayName() ->join('tenants', 'tenants.id', '=', 'findings.tenant_id') ->leftJoin('users as hygiene_assignee_lookup', 'hygiene_assignee_lookup.id', '=', 'findings.assignee_user_id') ->leftJoin('tenant_memberships as hygiene_assignee_membership', function ($join): void { $join ->on('hygiene_assignee_membership.tenant_id', '=', 'findings.tenant_id') ->on('hygiene_assignee_membership.user_id', '=', 'findings.assignee_user_id'); }) ->leftJoinSub( $this->latestMeaningfulWorkflowAuditSubquery(), 'hygiene_workflow_audit', function ($join): void { $join ->on('hygiene_workflow_audit.workspace_id', '=', 'findings.workspace_id') ->on('hygiene_workflow_audit.tenant_id', '=', 'findings.tenant_id') ->whereRaw('hygiene_workflow_audit.resource_id = '.$this->castFindingIdToAuditResourceId()); }, ) ->where('findings.workspace_id', (int) $workspace->getKey()) ->whereIn('findings.tenant_id', $visibleTenantIds === [] ? [-1] : $visibleTenantIds) ->whereIn('findings.status', Finding::openStatusesForQuery()) ->where(function (Builder $builder) use ($brokenAssignmentExpression, $staleInProgressExpression, $staleBindings): void { $builder ->whereRaw($brokenAssignmentExpression) ->orWhereRaw($staleInProgressExpression, $staleBindings); }); $this->applyReasonFilter($query, $reasonFilter, $brokenAssignmentExpression, $staleInProgressExpression, $staleBindings); if (! $applyOrdering) { return $query; } return $query ->orderByRaw( "case when {$brokenAssignmentExpression} then 0 when {$staleInProgressExpression} then 1 else 2 end asc", $staleBindings, ) ->orderByRaw("case when {$lastWorkflowActivityExpression} is null then 1 else 0 end asc") ->orderByRaw("{$lastWorkflowActivityExpression} asc") ->orderByRaw('case when findings.due_at is null then 1 else 0 end asc') ->orderBy('findings.due_at') ->orderBy('tenants.name') ->orderByDesc('findings.id'); } /** * @return array{unique_issue_count: int, broken_assignment_count: int, stale_in_progress_count: int} */ public function summary(Workspace $workspace, User $user, ?int $tenantId = null): array { $allIssues = $this->issueQuery($workspace, $user, $tenantId, self::FILTER_ALL, applyOrdering: false); $brokenAssignments = $this->issueQuery($workspace, $user, $tenantId, self::REASON_BROKEN_ASSIGNMENT, applyOrdering: false); $staleInProgress = $this->issueQuery($workspace, $user, $tenantId, self::REASON_STALE_IN_PROGRESS, applyOrdering: false); return [ 'unique_issue_count' => (clone $allIssues)->count(), 'broken_assignment_count' => (clone $brokenAssignments)->count(), 'stale_in_progress_count' => (clone $staleInProgress)->count(), ]; } /** * @return array */ public function filterOptions(): array { return [ self::FILTER_ALL => 'All issues', self::REASON_BROKEN_ASSIGNMENT => 'Broken assignment', self::REASON_STALE_IN_PROGRESS => 'Stale in progress', ]; } public function filterLabel(string $filter): string { return $this->filterOptions()[$filter] ?? $this->filterOptions()[self::FILTER_ALL]; } /** * @return list */ public function reasonLabelsFor(Finding $finding): array { $labels = []; if ($this->recordHasBrokenAssignment($finding)) { $labels[] = 'Broken assignment'; } if ($this->recordHasStaleInProgress($finding)) { $labels[] = 'Stale in progress'; } return $labels; } public function lastWorkflowActivityAt(Finding $finding): ?CarbonImmutable { return $this->findingWorkflowService->lastMeaningfulActivityAt( $finding, $finding->getAttribute('hygiene_last_workflow_activity_at'), ); } public function recordHasBrokenAssignment(Finding $finding): bool { return (int) ($finding->getAttribute('hygiene_is_broken_assignment') ?? 0) === 1; } public function recordHasStaleInProgress(Finding $finding): bool { return (int) ($finding->getAttribute('hygiene_is_stale_in_progress') ?? 0) === 1; } private function applyReasonFilter( Builder $query, string $reasonFilter, string $brokenAssignmentExpression, string $staleInProgressExpression, array $staleBindings, ): void { $resolvedFilter = array_key_exists($reasonFilter, $this->filterOptions()) ? $reasonFilter : self::FILTER_ALL; if ($resolvedFilter === self::REASON_BROKEN_ASSIGNMENT) { $query->whereRaw($brokenAssignmentExpression); return; } if ($resolvedFilter === self::REASON_STALE_IN_PROGRESS) { $query->whereRaw($staleInProgressExpression, $staleBindings); } } /** * @return Builder */ private function latestMeaningfulWorkflowAuditSubquery(): Builder { return AuditLog::query() ->selectRaw('workspace_id, tenant_id, resource_id, max(recorded_at) as latest_workflow_activity_at') ->where('resource_type', 'finding') ->whereIn('action', FindingWorkflowService::meaningfulActivityActionValues()) ->groupBy('workspace_id', 'tenant_id', 'resource_id'); } private function brokenAssignmentExpression(): string { return '(findings.assignee_user_id is not null and ((hygiene_assignee_lookup.id is not null and hygiene_assignee_lookup.deleted_at is not null) or hygiene_assignee_membership.id is null))'; } private function staleInProgressExpression(string $lastWorkflowActivityExpression): string { return sprintf( "(findings.status = '%s' and %s is not null and %s < ?)", Finding::STATUS_IN_PROGRESS, $lastWorkflowActivityExpression, $lastWorkflowActivityExpression, ); } private function lastWorkflowActivityExpression(): string { $baseline = "'".self::HYGIENE_BASELINE_TIMESTAMP."'"; $greatestExpression = match ($this->connectionDriver()) { 'pgsql', 'mysql' => sprintf( 'greatest(coalesce(findings.in_progress_at, %1$s), coalesce(findings.reopened_at, %1$s), coalesce(hygiene_workflow_audit.latest_workflow_activity_at, %1$s))', $baseline, ), default => sprintf( 'max(coalesce(findings.in_progress_at, %1$s), coalesce(findings.reopened_at, %1$s), coalesce(hygiene_workflow_audit.latest_workflow_activity_at, %1$s))', $baseline, ), }; return sprintf('nullif(%s, %s)', $greatestExpression, $baseline); } private function castFindingIdToAuditResourceId(): string { return match ($this->connectionDriver()) { 'pgsql' => 'findings.id::text', 'mysql' => 'cast(findings.id as char)', default => 'cast(findings.id as text)', }; } private function connectionDriver(): string { return Finding::query()->getConnection()->getDriverName(); } private function staleThreshold(): CarbonImmutable { return CarbonImmutable::now()->subDays(self::STALE_IN_PROGRESS_WINDOW_DAYS); } }