Some checks failed
Main Confidence / confidence (push) Failing after 1m20s
## 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
307 lines
12 KiB
PHP
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);
|
|
}
|
|
}
|