*/ 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() ->first(); if (! $websiteExtra) { return $tenant->websiteExtras()->create([ 'website_type_extra_id' => $definition->id, 'config' => [], 'is_enabled' => true, ]); } $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 => match ($rule) { 'image_or_base64' => new ImageOrBase64Rule, 'cropped_image_or_base64' => new CroppedImageOrBase64Rule, default => $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') ); } $crops = $this->cropVariants($value); $image = is_array($value) ? $value['image'] ?? null : $value; if (is_string($image) && Str::isUuid($image)) { $attachment = Attachment::query()->where('key', $image)->first(); if (! $attachment) { throw ValidationException::withMessages([ $this->configAttribute($requestRoot, $path) => [ 'The selected attachment does not exist.', ], ]); } if ($crops !== null) { $attachment = $this->attachmentService->updateImageCropVariants( $attachment, $crops, ); } } elseif ($crops !== null) { $attachment = $this->attachmentService->storeCroppedImageVariants( $image, "tenants/{$tenant->codigo}/extras/{$definition->codigo}", $crops, ); } else { $attachment = $this->attachmentService->store( $image, "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; } /** * Normalize the current variants payload and the original single-crop contract. * * @return array>|null */ private function cropVariants(mixed $value): ?array { if (! is_array($value)) { return null; } if (isset($value['crops']) && is_array($value['crops'])) { return $value['crops']; } $horizontal = $value['crop_horizontal'] ?? null; $vertical = $value['crop_vertical'] ?? null; if (! is_array($horizontal) || ! is_array($vertical)) { return null; } $crop = [ 'crop_horizontal' => $horizontal, 'crop_vertical' => $vertical, ]; return [ 'desktop' => $crop, 'mobile' => $crop, ]; } }