*/ public function requestRules(?string $websiteTypeCode): array { if (! is_string($websiteTypeCode) || $websiteTypeCode === '') { return [ 'extras' => ['prohibited'], ]; } $definitions = $this->definitionsFor($websiteTypeCode); $allowedNames = $definitions->pluck('nombre')->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 ($allowedNames): void { if (! is_array($value)) { return; } $unknownNames = array_diff(array_keys($value), $allowedNames); if ($unknownNames !== []) { $fail( 'The '.$attribute.' field contains extras not supported by the website type: ' .implode(', ', $unknownNames).'.' ); } }, ], ]; 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->nombre}"] = $rootRules; foreach ($schemaRules as $path => $pathRules) { if ($path === '$') { continue; } $rules[$this->requestAttribute($definition->nombre, $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('nombre'); 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."], ]); } $transformedConfig = $this->applyTransforms($tenant, $definition, $config); $this->validateDatabaseConfig($definition, $transformedConfig); $tenant->websiteExtras()->create([ 'website_type_extra_id' => $definition->id, 'config' => $transformedConfig, ]); } $tenant->unsetRelation('websiteExtras'); } /** * @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 $extraName, string $path): string { if ($path === '$') { return "extras.{$extraName}"; } if (str_starts_with($path, '$.')) { $path = substr($path, 2); } return "extras.{$extraName}.{$path}"; } private function applyTransforms( Tenant $tenant, WebsiteTypeExtra $definition, mixed $config ): 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 ) ); } 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 ): mixed { if ($value === null) { return null; } if (($transform['handler'] ?? null) !== 'attachment') { throw new InvalidArgumentException( "Unsupported transform handler for {$definition->nombre}: ".($transform['handler'] ?? 'null') ); } if (is_string($value) && Str::isUuid($value)) { $attachment = Attachment::query()->where('key', $value)->first(); if (! $attachment) { throw ValidationException::withMessages([ $this->requestAttribute($definition->nombre, $path) => [ 'The selected attachment does not exist.', ], ]); } } else { $attachment = $this->attachmentService->store( $value, "tenants/{$tenant->codigo}/extras/{$definition->nombre}" ); } $expectedType = $transform['attachment_type'] ?? null; if ( is_string($expectedType) && $attachment->type instanceof AttachmentType && $attachment->type->value !== $expectedType ) { throw ValidationException::withMessages([ $this->requestAttribute($definition->nombre, $path) => [ "The attachment must be of type {$expectedType}.", ], ]); } return $attachment->id; } private function validateDatabaseConfig( WebsiteTypeExtra $definition, mixed $config ): void { $schemaRules = $definition->config_schema['database_rules'] ?? []; $rules = []; foreach ($schemaRules as $path => $pathRules) { $attribute = $path === '$' ? 'config' : 'config.'.ltrim(str_starts_with($path, '$.') ? substr($path, 2) : $path, '.'); $rules[$attribute] = $this->compileRules($pathRules); } $validator = Validator::make(['config' => $config], $rules); if ($validator->fails()) { $messages = []; foreach ($validator->errors()->toArray() as $attribute => $errors) { $suffix = $attribute === 'config' ? '' : substr($attribute, strlen('config')); $messages["extras.{$definition->nombre}{$suffix}"] = $errors; } throw ValidationException::withMessages($messages); } } }