*/ public function requestRules(?string $websiteTypeCode): array { if (! is_string($websiteTypeCode) || $websiteTypeCode === '') { return [ 'extras' => ['prohibited'], ]; } $definitions = $this->definitionsFor($websiteTypeCode); $allowedCodes = $definitions->pluck('codigo')->all(); $hasRequiredExtras = $definitions->contains( fn (WebsiteTypeExtra $definition): bool => $definition->is_required ); $rules = [ 'extras' => [ $hasRequiredExtras ? 'required' : 'sometimes', 'array', function (string $attribute, mixed $value, \Closure $fail) use ($allowedCodes): void { if (! is_array($value)) { return; } $unknownCodes = array_diff(array_keys($value), $allowedCodes); if ($unknownCodes !== []) { $fail( 'The '.$attribute.' field contains extras not supported by the website type: ' .implode(', ', $unknownCodes).'.' ); } }, ], ]; foreach ($definitions as $definition) { $schemaRules = $definition->config_schema['request_rules'] ?? []; $rootRules = $this->compileRules($schemaRules['$'] ?? []); $rootRules = array_values(array_filter( $rootRules, fn (mixed $rule): bool => ! in_array($rule, ['required', 'sometimes'], true) )); array_unshift($rootRules, $definition->is_required ? 'required' : 'sometimes'); $rules["extras.{$definition->codigo}"] = $rootRules; foreach ($schemaRules as $path => $pathRules) { if ($path === '$') { continue; } $rules[$this->requestAttribute($definition->codigo, $path)] = $this->compileRules($pathRules); } } return $rules; } /** * Transform and persist each extra selected for a tenant. * * @param array $extras */ public function createForTenant(Tenant $tenant, array $extras): void { if ($extras === []) { return; } $definitions = $this->definitionsFor((string) $tenant->website_type_code) ->keyBy('codigo'); foreach ($extras as $name => $config) { /** @var WebsiteTypeExtra|null $definition */ $definition = $definitions->get($name); if (! $definition) { throw ValidationException::withMessages([ 'extras' => ["The extra {$name} is not supported by the selected website type."], ]); } $requestRoot = "extras.{$definition->codigo}"; $transformedConfig = $this->applyTransforms( $tenant, $definition, $config, $requestRoot ); $tenant->websiteExtras()->create([ 'website_type_extra_id' => $definition->id, 'config' => $transformedConfig, ]); } $tenant->unsetRelation('websiteExtras'); } /** * Build request rules for one extra addressed by its stable code. * * @return array */ public function requestRulesForExtra(Tenant $tenant, string $extraCode): array { $definition = $this->definitionForTenant($tenant, $extraCode); $schemaRules = $definition->config_schema['request_rules'] ?? []; $rootRules = $this->compileRules($schemaRules['$'] ?? []); $rootRules = array_values(array_filter( $rootRules, fn (mixed $rule): bool => ! in_array($rule, ['required', 'sometimes'], true) )); array_unshift($rootRules, 'required'); $rules = ['config' => $rootRules]; foreach ($schemaRules as $path => $pathRules) { if ($path === '$') { continue; } $rules[$this->configAttribute('config', $path)] = $this->compileRules($pathRules); } return $rules; } public function updateForTenant(Tenant $tenant, string $extraCode, mixed $config): WebsiteExtra { $definition = $this->definitionForTenant($tenant, $extraCode); return DB::transaction(function () use ($tenant, $definition, $config): WebsiteExtra { $transformedConfig = $this->applyTransforms($tenant, $definition, $config, 'config'); return $tenant->websiteExtras()->updateOrCreate( ['website_type_extra_id' => $definition->id], ['config' => $transformedConfig] ); }); } public function toggleForTenant(Tenant $tenant, string $extraCode): WebsiteExtra { $definition = $this->definitionForTenant($tenant, $extraCode); return DB::transaction(function () use ($tenant, $definition): WebsiteExtra { $websiteExtra = $tenant->websiteExtras() ->where('website_type_extra_id', $definition->id) ->lockForUpdate() ->firstOrFail(); $websiteExtra->update([ 'is_enabled' => ! $websiteExtra->is_enabled, ]); return $websiteExtra; }); } public function definitionForTenant(Tenant $tenant, string $extraCode): WebsiteTypeExtra { return WebsiteTypeExtra::query() ->where('website_type_code', $tenant->website_type_code) ->where('codigo', $extraCode) ->firstOrFail(); } /** * @return Collection */ private function definitionsFor(string $websiteTypeCode): Collection { $websiteType = WebsiteType::query() ->where('codigo', $websiteTypeCode) ->with('extras') ->first(); return $websiteType?->extras ?? collect(); } /** * @param string|array $rules * @return array */ private function compileRules(string|array $rules): array { $compiled = is_string($rules) ? explode('|', $rules) : $rules; return array_map( fn (mixed $rule): mixed => $rule === 'image_or_base64' ? new ImageOrBase64Rule : $rule, $compiled ); } private function requestAttribute(string $extraCode, string $path): string { return $this->configAttribute("extras.{$extraCode}", $path); } private function configAttribute(string $root, string $path): string { if ($path === '$') { return $root; } if (str_starts_with($path, '$.')) { $path = substr($path, 2); } return "{$root}.{$path}"; } private function applyTransforms( Tenant $tenant, WebsiteTypeExtra $definition, mixed $config, string $requestRoot ): mixed { foreach ($definition->config_schema['transforms'] ?? [] as $path => $transform) { $segments = $this->pathSegments($path); $config = $this->transformAtPath( $config, $segments, fn (mixed $value): mixed => $this->transformValue( $tenant, $definition, $path, $value, $transform, $requestRoot ) ); } return $config; } /** * @return array */ private function pathSegments(string $path): array { $path = ltrim($path, '$'); $path = ltrim($path, '.'); return $path === '' ? [] : explode('.', $path); } private function transformAtPath(mixed $value, array $segments, callable $transform): mixed { if ($segments === []) { return $transform($value); } if (! is_array($value)) { return $value; } $segment = array_shift($segments); if ($segment === '*') { foreach ($value as $key => $item) { $value[$key] = $this->transformAtPath($item, $segments, $transform); } return $value; } if (array_key_exists($segment, $value)) { $value[$segment] = $this->transformAtPath($value[$segment], $segments, $transform); } return $value; } /** * @param array $transform */ private function transformValue( Tenant $tenant, WebsiteTypeExtra $definition, string $path, mixed $value, array $transform, string $requestRoot ): mixed { if ($value === null) { return null; } if (($transform['handler'] ?? null) !== 'attachment') { throw new InvalidArgumentException( "Unsupported transform handler for {$definition->codigo}: ".($transform['handler'] ?? 'null') ); } if (is_string($value) && Str::isUuid($value)) { $attachment = Attachment::query()->where('key', $value)->first(); if (! $attachment) { throw ValidationException::withMessages([ $this->configAttribute($requestRoot, $path) => [ 'The selected attachment does not exist.', ], ]); } } else { $attachment = $this->attachmentService->store( $value, "tenants/{$tenant->codigo}/extras/{$definition->codigo}" ); } $expectedType = $transform['attachment_type'] ?? null; if ( is_string($expectedType) && $attachment->type instanceof AttachmentType && $attachment->type->value !== $expectedType ) { throw ValidationException::withMessages([ $this->configAttribute($requestRoot, $path) => [ "The attachment must be of type {$expectedType}.", ], ]); } return $attachment->id; } }