*/ 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, ]); } }