feat(tenant): implement website extras functionality and validation in tenant creation

This commit is contained in:
2026-07-29 15:16:33 -03:00
parent 9e3163b74f
commit 27d4f9fac6
7 changed files with 520 additions and 8 deletions

View File

@@ -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\": \"<h1>Festival Demo</h1>\",\n \"description_html\": \"<p>Una experiencia inolvidable</p>\",\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."
}
]
},

View File

@@ -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);
}

View File

@@ -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')));
}
}

View File

@@ -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),

View File

@@ -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;
});

View File

@@ -0,0 +1,305 @@
<?php
namespace App\Domains\Tenant\Services;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Tenant\Models\WebsiteTypeExtra;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
class WebsiteExtraService
{
public function __construct(protected AttachmentService $attachmentService) {}
/**
* Build the request rules declared by the selected website type.
*
* @return array<string, mixed>
*/
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<string, mixed> $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<int, WebsiteTypeExtra>
*/
private function definitionsFor(string $websiteTypeCode): Collection
{
$websiteType = WebsiteType::query()
->where('codigo', $websiteTypeCode)
->with('extras')
->first();
return $websiteType?->extras ?? collect();
}
/**
* @param string|array<int, mixed> $rules
* @return array<int, mixed>
*/
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<int, string>
*/
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<string, mixed> $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);
}
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace Tests\Feature\Tenant;
use App\Domains\Tenant\Models\Tenant;
use Database\Seeders\WebsiteTypeSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class StoreTenantWithExtrasTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('s3');
$this->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<string, mixed>
*/
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,
];
}
}