TenantAtlas/apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php
ahmido 12fb5ebb30
Some checks failed
Main Confidence / confidence (push) Failing after 1m20s
feat: add findings hygiene report and control catalog layering (#264)
## Summary
- add the workspace-scoped findings hygiene report, overview signal, and supporting classification service for broken assignments and stale in-progress work
- add Spec 225 artifacts and focused findings hygiene test coverage alongside the new Filament page and workspace overview wiring
- align product roadmap and spec candidates around the layered canonical control catalog, CIS library, and readiness model
- extend SpecKit constitution and templates with the XCUT-001 shared-pattern reuse guidance

## Notes
- validation commands and implementation close-out notes are documented in `specs/225-assignment-hygiene/plan.md` and `specs/225-assignment-hygiene/quickstart.md`
- this PR targets `dev` from `225-assignment-hygiene`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #264
2026-04-22 12:26:18 +00:00

307 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Findings;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
final class FindingAssignmentHygieneService
{
public const string FILTER_ALL = 'all';
public const string REASON_BROKEN_ASSIGNMENT = 'broken_assignment';
public const string REASON_STALE_IN_PROGRESS = 'stale_in_progress';
private const string HYGIENE_BASELINE_TIMESTAMP = '1970-01-01 00:00:00';
private const int STALE_IN_PROGRESS_WINDOW_DAYS = 7;
public function __construct(
private readonly CapabilityResolver $capabilityResolver,
private readonly FindingWorkflowService $findingWorkflowService,
) {}
/**
* @return array<int, Tenant>
*/
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<Finding>
*/
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<string, string>
*/
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<string>
*/
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<AuditLog>
*/
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);
}
}