From 27d4f9fac6ef56c7004eae4e2f9376e604ed4909 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 29 Jul 2026 15:16:33 -0300 Subject: [PATCH] feat(tenant): implement website extras functionality and validation in tenant creation --- ShopIt_API_Postman_Collection.json | 37 ++- .../Tenant/Controllers/TenantController.php | 8 +- .../Tenant/Requests/StoreTenantRequest.php | 11 +- .../Tenant/Resources/TenantResource.php | 15 + app/Domains/Tenant/Services/TenantService.php | 10 +- .../Tenant/Services/WebsiteExtraService.php | 305 ++++++++++++++++++ .../Tenant/StoreTenantWithExtrasTest.php | 142 ++++++++ 7 files changed, 520 insertions(+), 8 deletions(-) create mode 100644 app/Domains/Tenant/Services/WebsiteExtraService.php create mode 100644 tests/Feature/Tenant/StoreTenantWithExtrasTest.php diff --git a/ShopIt_API_Postman_Collection.json b/ShopIt_API_Postman_Collection.json index c69f8a3..e785808 100644 --- a/ShopIt_API_Postman_Collection.json +++ b/ShopIt_API_Postman_Collection.json @@ -34,7 +34,7 @@ "response": [] }, { - "name": "Create Tenant (JSON)", + "name": "Create Tenant OnTicket (JSON)", "request": { "method": "POST", "header": [ @@ -51,7 +51,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"codigo\": \"acme\",\n \"nombre\": \"Acme\",\n \"dominio\": \"acme.test\",\n \"primary_color\": \"#111111\",\n \"secondary_color\": \"#222222\",\n \"danger_color\": \"#ff0000\",\n \"header_bg_color\": \"#333333\",\n \"footer_bg_color\": \"#333333\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n}" + "raw": "{\n \"codigo\": \"festival-demo\",\n \"nombre\": \"Festival Demo\",\n \"dominio\": \"festival-demo.test\",\n \"primary_color\": \"#111111\",\n \"secondary_color\": \"#222222\",\n \"danger_color\": \"#ff0000\",\n \"success_color\": \"#00aa55\",\n \"header_bg_color\": \"#333333\",\n \"footer_bg_color\": \"#333333\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"search_product_layout\": \"column_with_image\",\n \"search_group_layout\": \"paginated\",\n \"search_items_per_page\": 12,\n \"website_type_code\": \"onticket\",\n \"extras\": {\n \"heroConfig\": {\n \"title_html\": \"

Festival Demo

\",\n \"description_html\": \"

Una experiencia inolvidable

\",\n \"button_text\": \"Comprar entradas\",\n \"button_href\": \"/tickets\",\n \"background_image_id\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n },\n \"eventConfig\": {\n \"title\": \"Festival Demo\",\n \"location\": \"Buenos Aires\",\n \"dates\": [\n \"2026-10-10\",\n \"2026-10-11\"\n ]\n }\n }\n}" }, "url": { "raw": "{{base_url}}/api/tenants", @@ -67,7 +67,7 @@ "response": [] }, { - "name": "Create Tenant (Form Data)", + "name": "Create Tenant ShopIt (Form Data)", "request": { "method": "POST", "header": [ @@ -110,6 +110,11 @@ "value": "#ff0000", "type": "text" }, + { + "key": "success_color", + "value": "#00aa55", + "type": "text" + }, { "key": "header_bg_color", "value": "#333333", @@ -129,6 +134,32 @@ "key": "footer_logo", "type": "file", "src": [] + }, + { + "key": "search_product_layout", + "value": "column_with_image", + "type": "text" + }, + { + "key": "search_group_layout", + "value": "paginated", + "type": "text" + }, + { + "key": "search_items_per_page", + "value": "12", + "type": "text" + }, + { + "key": "website_type_code", + "value": "shopit", + "type": "text" + }, + { + "key": "extras[carousel][]", + "type": "file", + "src": [], + "description": "Se puede repetir esta key hasta 10 veces. TambiƩn acepta una imagen base64 o el UUID de un attachment." } ] }, diff --git a/app/Domains/Tenant/Controllers/TenantController.php b/app/Domains/Tenant/Controllers/TenantController.php index 6fea5af..24c7f67 100644 --- a/app/Domains/Tenant/Controllers/TenantController.php +++ b/app/Domains/Tenant/Controllers/TenantController.php @@ -30,7 +30,13 @@ class TenantController extends Controller $tenant = $this->tenantService->create($request->validated()); return TenantResource::make( - $tenant->loadMissing(['headerLogo', 'footerLogo', 'socialMedia']) + $tenant->loadMissing([ + 'headerLogo', + 'footerLogo', + 'socialMedia', + 'websiteType', + 'websiteExtras.websiteTypeExtra', + ]) )->response()->setStatusCode(201); } diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index f808d88..7485980 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -5,6 +5,7 @@ namespace App\Domains\Tenant\Requests; use App\Domains\Catalog\Enums\GroupLayout; use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Shared\Rules\ImageOrBase64Rule; +use App\Domains\Tenant\Services\WebsiteExtraService; use App\Domains\Tenant\Support\TenantDomainNormalizer; use Closure; use Illuminate\Foundation\Http\FormRequest; @@ -39,7 +40,7 @@ class StoreTenantRequest extends FormRequest { $logoRule = ['required', new ImageOrBase64Rule]; - return [ + return array_merge([ 'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')], 'nombre' => ['required', 'string', 'max:255'], 'dominio' => [ @@ -74,6 +75,12 @@ class StoreTenantRequest extends FormRequest 'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)], 'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)], 'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'], - ]; + 'website_type_code' => [ + 'required_with:extras', + 'sometimes', + 'string', + Rule::exists('website_type', 'codigo'), + ], + ], app(WebsiteExtraService::class)->requestRules($this->input('website_type_code'))); } } diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index 1f6e142..28e2c7d 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -30,6 +30,21 @@ class TenantResource extends JsonResource 'success_color' => $this->success_color, 'header_bg_color' => $this->header_bg_color, 'footer_bg_color' => $this->footer_bg_color, + 'website_type_code' => $this->website_type_code, + 'website_type' => $this->whenLoaded( + 'websiteType', + fn () => $this->websiteType ? [ + 'codigo' => $this->websiteType->codigo, + 'nombre' => $this->websiteType->nombre, + ] : null + ), + 'extras' => $this->whenLoaded( + 'websiteExtras', + fn () => $this->websiteExtras + ->mapWithKeys(fn ($extra) => [ + $extra->websiteTypeExtra->nombre => $extra->config, + ]) + ), // 1 day 'header_logo' => $this->headerLogo?->getTemporaryUrl(1440), 'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440), diff --git a/app/Domains/Tenant/Services/TenantService.php b/app/Domains/Tenant/Services/TenantService.php index 36e74ce..c2692e1 100644 --- a/app/Domains/Tenant/Services/TenantService.php +++ b/app/Domains/Tenant/Services/TenantService.php @@ -10,7 +10,10 @@ use Illuminate\Support\Str; class TenantService { - public function __construct(protected AttachmentService $attachmentService) {} + public function __construct( + protected AttachmentService $attachmentService, + protected WebsiteExtraService $websiteExtraService, + ) {} /** * Create a new tenant and store its logos. @@ -23,11 +26,13 @@ class TenantService $headerLogo = $data['header_logo'] ?? null; $footerLogo = $data['footer_logo'] ?? null; $socialMedia = $data['social_media'] ?? []; + $extras = $data['extras'] ?? []; unset( $data['header_logo'], $data['footer_logo'], - $data['social_media'] + $data['social_media'], + $data['extras'], ); $headerAttachmentId = null; @@ -58,6 +63,7 @@ class TenantService /** @var Tenant $tenant */ $tenant = Tenant::query()->create($data); $this->syncSocialMedia($tenant, $socialMedia); + $this->websiteExtraService->createForTenant($tenant, $extras); return $tenant; }); diff --git a/app/Domains/Tenant/Services/WebsiteExtraService.php b/app/Domains/Tenant/Services/WebsiteExtraService.php new file mode 100644 index 0000000..443ff25 --- /dev/null +++ b/app/Domains/Tenant/Services/WebsiteExtraService.php @@ -0,0 +1,305 @@ + + */ + 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); + } + } +} diff --git a/tests/Feature/Tenant/StoreTenantWithExtrasTest.php b/tests/Feature/Tenant/StoreTenantWithExtrasTest.php new file mode 100644 index 0000000..58b72e0 --- /dev/null +++ b/tests/Feature/Tenant/StoreTenantWithExtrasTest.php @@ -0,0 +1,142 @@ +seed(WebsiteTypeSeeder::class); + } + + public function test_it_creates_a_tenant_and_validates_and_stores_its_website_extras(): void + { + $response = $this->postJson('/api/tenants', array_merge($this->tenantData(), [ + 'website_type_code' => 'onticket', + 'extras' => [ + 'eventConfig' => [ + 'title' => 'Festival', + 'location' => 'Buenos Aires', + 'dates' => ['2026-10-10', '2026-10-11'], + ], + ], + ])); + + $response + ->assertCreated() + ->assertJsonPath('data.website_type_code', 'onticket') + ->assertJsonPath('data.website_type.codigo', 'onticket') + ->assertJsonPath('data.extras.eventConfig.title', 'Festival'); + + $tenant = Tenant::query()->where('codigo', 'festival')->sole(); + + $this->assertDatabaseHas('websites_extras', [ + 'website_code' => $tenant->codigo, + ]); + $this->assertSame( + [ + 'title' => 'Festival', + 'location' => 'Buenos Aires', + 'dates' => ['2026-10-10', '2026-10-11'], + ], + $tenant->websiteExtras()->sole()->config + ); + } + + public function test_it_rejects_an_extra_not_supported_by_the_selected_website_type(): void + { + $this->postJson('/api/tenants', array_merge($this->tenantData(), [ + 'website_type_code' => 'shopit', + 'extras' => [ + 'eventConfig' => [ + 'title' => 'Not supported', + ], + ], + ])) + ->assertUnprocessable() + ->assertJsonValidationErrors(['extras']); + + $this->assertDatabaseMissing('tenants', ['codigo' => 'festival']); + } + + public function test_it_applies_the_extra_schema_rules(): void + { + $this->postJson('/api/tenants', array_merge($this->tenantData(), [ + 'website_type_code' => 'onticket', + 'extras' => [ + 'eventConfig' => [ + 'dates' => ['not-a-date'], + ], + ], + ])) + ->assertUnprocessable() + ->assertJsonValidationErrors(['extras.eventConfig.dates.0']); + + $this->assertDatabaseMissing('tenants', ['codigo' => 'festival']); + } + + public function test_it_transforms_extra_images_to_attachment_ids(): void + { + $image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + $response = $this->postJson('/api/tenants', array_merge($this->tenantData(), [ + 'website_type_code' => 'shopit', + 'extras' => [ + 'carousel' => [$image], + ], + ])); + + $response->assertCreated(); + + $config = Tenant::query() + ->where('codigo', 'festival') + ->sole() + ->websiteExtras() + ->sole() + ->config; + + $this->assertCount(1, $config); + $this->assertIsInt($config[0]); + $this->assertDatabaseHas('attachments', [ + 'id' => $config[0], + 'type' => 'image', + ]); + $response->assertJsonPath('data.extras.carousel.0', $config[0]); + } + + /** + * @return array + */ + private function tenantData(): array + { + $image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + return [ + 'codigo' => 'festival', + 'nombre' => 'Festival', + 'dominio' => 'festival.test', + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#333333', + 'success_color' => '#444444', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#ffffff', + 'header_logo' => $image, + 'footer_logo' => $image, + 'search_product_layout' => 'column_with_image', + 'search_group_layout' => 'paginated', + 'search_items_per_page' => 12, + ]; + } +}