Implements spec 111 (Findings workflow + SLA) and fixes Workspace findings SLA settings UX/validation. Key changes: - Findings workflow service + SLA policy and alerting. - Workspace settings: allow partial SLA overrides without auto-filling unset severities in the UI; effective values still resolve via defaults. - New migrations, jobs, command, UI/resource updates, and comprehensive test coverage. Tests: - `vendor/bin/sail artisan test --compact` (1779 passed, 8 skipped). Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #135
159 lines
5.1 KiB
PHP
159 lines
5.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Settings;
|
|
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantSetting;
|
|
use App\Models\Workspace;
|
|
use App\Models\WorkspaceSetting;
|
|
use App\Support\Settings\SettingDefinition;
|
|
use App\Support\Settings\SettingsRegistry;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class SettingsResolver
|
|
{
|
|
/**
|
|
* @var array<string, array{domain: string, key: string, value: mixed, source: 'system_default'|'workspace_override'|'tenant_override', system_default: mixed, workspace_value: mixed, tenant_value: mixed}>
|
|
*/
|
|
private array $resolved = [];
|
|
|
|
public function __construct(private SettingsRegistry $registry) {}
|
|
|
|
/**
|
|
* @return array{domain: string, key: string, value: mixed, source: 'system_default'|'workspace_override'|'tenant_override', system_default: mixed, workspace_value: mixed, tenant_value: mixed}
|
|
*/
|
|
public function resolveDetailed(Workspace $workspace, string $domain, string $key, ?Tenant $tenant = null): array
|
|
{
|
|
if ($tenant instanceof Tenant) {
|
|
$this->assertTenantBelongsToWorkspace($workspace, $tenant);
|
|
}
|
|
|
|
$cacheKey = $this->cacheKey($workspace, $domain, $key, $tenant);
|
|
|
|
if (isset($this->resolved[$cacheKey])) {
|
|
return $this->resolved[$cacheKey];
|
|
}
|
|
|
|
$definition = $this->registry->require($domain, $key);
|
|
|
|
$workspaceValue = $this->workspaceOverrideValue($workspace, $domain, $key);
|
|
$tenantValue = $tenant instanceof Tenant
|
|
? $this->tenantOverrideValue($workspace, $tenant, $domain, $key)
|
|
: null;
|
|
|
|
$source = 'system_default';
|
|
$rawValue = $definition->systemDefault;
|
|
|
|
if ($workspaceValue !== null) {
|
|
$source = 'workspace_override';
|
|
$rawValue = $workspaceValue;
|
|
}
|
|
|
|
if ($tenantValue !== null) {
|
|
$source = 'tenant_override';
|
|
$rawValue = $tenantValue;
|
|
}
|
|
|
|
$effectiveValue = $this->mergeWithDefault($definition, $rawValue);
|
|
|
|
return $this->resolved[$cacheKey] = [
|
|
'domain' => $domain,
|
|
'key' => $key,
|
|
'value' => $effectiveValue,
|
|
'source' => $source,
|
|
'system_default' => $definition->systemDefault,
|
|
'workspace_value' => $workspaceValue,
|
|
'tenant_value' => $tenantValue,
|
|
];
|
|
}
|
|
|
|
public function resolveValue(Workspace $workspace, string $domain, string $key, ?Tenant $tenant = null): mixed
|
|
{
|
|
return $this->resolveDetailed($workspace, $domain, $key, $tenant)['value'];
|
|
}
|
|
|
|
public function clearCache(): void
|
|
{
|
|
$this->resolved = [];
|
|
}
|
|
|
|
/**
|
|
* For JSON settings that store partial overrides (e.g. SLA days with only
|
|
* some severities set), merge the stored partial with the system default
|
|
* so consumers always receive a complete value.
|
|
*/
|
|
private function mergeWithDefault(SettingDefinition $definition, mixed $value): mixed
|
|
{
|
|
if ($definition->type !== 'json') {
|
|
return $value;
|
|
}
|
|
|
|
if (! is_array($value) || ! is_array($definition->systemDefault)) {
|
|
return $value;
|
|
}
|
|
|
|
return array_replace($definition->systemDefault, $value);
|
|
}
|
|
|
|
private function workspaceOverrideValue(Workspace $workspace, string $domain, string $key): mixed
|
|
{
|
|
$setting = WorkspaceSetting::query()
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->where('domain', $domain)
|
|
->where('key', $key)
|
|
->first(['value']);
|
|
|
|
if (! $setting instanceof WorkspaceSetting) {
|
|
return null;
|
|
}
|
|
|
|
return $this->decodeStoredValue($setting->getAttribute('value'));
|
|
}
|
|
|
|
private function tenantOverrideValue(Workspace $workspace, Tenant $tenant, string $domain, string $key): mixed
|
|
{
|
|
$setting = TenantSetting::query()
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('domain', $domain)
|
|
->where('key', $key)
|
|
->first(['value']);
|
|
|
|
if (! $setting instanceof TenantSetting) {
|
|
return null;
|
|
}
|
|
|
|
return $this->decodeStoredValue($setting->getAttribute('value'));
|
|
}
|
|
|
|
private function decodeStoredValue(mixed $value): mixed
|
|
{
|
|
if (! is_string($value)) {
|
|
return $value;
|
|
}
|
|
|
|
$decoded = json_decode($value, true);
|
|
|
|
return json_last_error() === JSON_ERROR_NONE ? $decoded : $value;
|
|
}
|
|
|
|
private function assertTenantBelongsToWorkspace(Workspace $workspace, Tenant $tenant): void
|
|
{
|
|
if ((int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
|
throw new NotFoundHttpException('Tenant is outside the selected workspace scope.');
|
|
}
|
|
}
|
|
|
|
private function cacheKey(Workspace $workspace, string $domain, string $key, ?Tenant $tenant): string
|
|
{
|
|
return implode(':', [
|
|
(string) $workspace->getKey(),
|
|
(string) ($tenant?->getKey() ?? 0),
|
|
$domain,
|
|
$key,
|
|
]);
|
|
}
|
|
}
|