diff --git a/app/Domains/Tenant/Controllers/BootstrapTenantController.php b/app/Domains/Tenant/Controllers/BootstrapTenantController.php new file mode 100644 index 0000000..164ca1b --- /dev/null +++ b/app/Domains/Tenant/Controllers/BootstrapTenantController.php @@ -0,0 +1,21 @@ +validated('dominio'); + + return TenantResource::make( + Tenant::query()->where('dominio', $dominio)->firstOrFail() + ); + } +} diff --git a/app/Domains/Tenant/Requests/BootstrapTenantRequest.php b/app/Domains/Tenant/Requests/BootstrapTenantRequest.php new file mode 100644 index 0000000..6d6bb86 --- /dev/null +++ b/app/Domains/Tenant/Requests/BootstrapTenantRequest.php @@ -0,0 +1,50 @@ +route('dominio'); + $normalizedDomain = TenantDomainNormalizer::normalize($rawDomain); + + $this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain) + && $normalizedDomain === null; + + $this->merge([ + 'dominio' => $normalizedDomain, + ]); + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'dominio' => [ + 'bail', + function (string $attribute, mixed $value, Closure $fail): void { + if ($this->hasInvalidDomain) { + $fail("The {$attribute} field must contain a valid domain or URL."); + } + }, + 'required', + 'string', + 'max:255', + ], + ]; + } +} diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index af26900..60d0623 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -3,16 +3,33 @@ namespace App\Domains\Tenant\Requests; use App\Domains\Prop\Support\PropRules; +use App\Domains\Tenant\Support\TenantDomainNormalizer; +use Closure; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class StoreTenantRequest extends FormRequest { + protected bool $hasInvalidDomain = false; + public function authorize(): bool { return true; } + protected function prepareForValidation(): void + { + $rawDomain = $this->input('dominio'); + $normalizedDomain = TenantDomainNormalizer::normalize($rawDomain); + + $this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain) + && $normalizedDomain === null; + + $this->merge([ + 'dominio' => $normalizedDomain, + ]); + } + /** * @return array */ @@ -21,7 +38,18 @@ class StoreTenantRequest extends FormRequest return [ 'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')], 'nombre' => ['required', 'string', 'max:255'], - 'dominio' => ['nullable', 'string', 'max:255'], + 'dominio' => [ + 'bail', + function (string $attribute, mixed $value, Closure $fail): void { + if ($this->hasInvalidDomain) { + $fail("The {$attribute} field must contain a valid domain or URL."); + } + }, + 'nullable', + 'string', + 'max:255', + Rule::unique('tenants', 'dominio'), + ], ...PropRules::sync(), ]; } diff --git a/app/Domains/Tenant/Requests/UpdateTenantRequest.php b/app/Domains/Tenant/Requests/UpdateTenantRequest.php index ab37ce2..7246ca2 100644 --- a/app/Domains/Tenant/Requests/UpdateTenantRequest.php +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -4,16 +4,33 @@ namespace App\Domains\Tenant\Requests; use App\Domains\Prop\Support\PropRules; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Tenant\Support\TenantDomainNormalizer; +use Closure; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class UpdateTenantRequest extends FormRequest { + protected bool $hasInvalidDomain = false; + public function authorize(): bool { return true; } + protected function prepareForValidation(): void + { + $rawDomain = $this->input('dominio'); + $normalizedDomain = TenantDomainNormalizer::normalize($rawDomain); + + $this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain) + && $normalizedDomain === null; + + $this->merge([ + 'dominio' => $normalizedDomain, + ]); + } + /** * @return array */ @@ -30,7 +47,18 @@ class UpdateTenantRequest extends FormRequest Rule::unique('tenants', 'codigo')->ignore($tenant?->id), ], 'nombre' => ['required', 'string', 'max:255'], - 'dominio' => ['nullable', 'string', 'max:255'], + 'dominio' => [ + 'bail', + function (string $attribute, mixed $value, Closure $fail): void { + if ($this->hasInvalidDomain) { + $fail("The {$attribute} field must contain a valid domain or URL."); + } + }, + 'nullable', + 'string', + 'max:255', + Rule::unique('tenants', 'dominio')->ignore($tenant?->id), + ], ...PropRules::sync(), ]; } diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index aa5cac4..623d9a9 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -21,9 +21,7 @@ class TenantResource extends JsonResource 'codigo' => $this->codigo, 'nombre' => $this->nombre, 'dominio' => $this->dominio, - 'props' => PropValueResource::collapsedFromPropable($this->resource), - 'created_at' => $this->created_at, - 'updated_at' => $this->updated_at, + 'props' => PropValueResource::collapsedFromPropable($this->resource) ]; } } diff --git a/app/Domains/Tenant/Support/TenantDomainNormalizer.php b/app/Domains/Tenant/Support/TenantDomainNormalizer.php new file mode 100644 index 0000000..99450ce --- /dev/null +++ b/app/Domains/Tenant/Support/TenantDomainNormalizer.php @@ -0,0 +1,36 @@ +where('dominio', '.*'); + Route::apiResource('tenants', TenantController::class); Route::apiResource('tenant-props', TenantPropController::class) ->parameters(['tenant-props' => 'tenantProp']); diff --git a/database/migrations/2026_06_19_010000_create_tenant_props_table.php b/database/migrations/2026_06_19_010000_create_tenant_props_table.php deleted file mode 100644 index a68eac8..0000000 --- a/database/migrations/2026_06_19_010000_create_tenant_props_table.php +++ /dev/null @@ -1,29 +0,0 @@ -id(); - $table->string('codigo')->unique(); - $table->string('prop_type'); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('tenant_props'); - } -}; diff --git a/database/migrations/2026_06_23_000000_normalize_and_make_tenant_domains_unique.php b/database/migrations/2026_06_23_000000_normalize_and_make_tenant_domains_unique.php new file mode 100644 index 0000000..36d4572 --- /dev/null +++ b/database/migrations/2026_06_23_000000_normalize_and_make_tenant_domains_unique.php @@ -0,0 +1,107 @@ +select(['id', 'dominio']) + ->orderBy('id') + ->get(); + + foreach ($tenants as $tenant) { + $normalizedDomain = $this->normalizeDomain($tenant->dominio, $tenant->id); + + if ($normalizedDomain === null) { + continue; + } + + $normalizedDomains[$tenant->id] = $normalizedDomain; + $duplicateTenantIdsByDomain[$normalizedDomain][] = $tenant->id; + } + + $duplicates = array_filter( + $duplicateTenantIdsByDomain, + static fn (array $tenantIds): bool => count($tenantIds) > 1, + ); + + if ($duplicates !== []) { + $duplicateSummary = array_map( + static fn (string $domain, array $tenantIds): string => sprintf( + '%s => [%s]', + $domain, + implode(', ', $tenantIds), + ), + array_keys($duplicates), + array_values($duplicates), + ); + + throw new RuntimeException( + 'Cannot create a unique index for tenants.dominio because normalized duplicates exist: ' + .implode('; ', $duplicateSummary) + ); + } + + foreach ($normalizedDomains as $tenantId => $normalizedDomain) { + DB::table('tenants') + ->where('id', $tenantId) + ->update(['dominio' => $normalizedDomain]); + } + + Schema::table('tenants', function (Blueprint $table) { + $table->unique('dominio', 'tenants_dominio_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropUnique('tenants_dominio_unique'); + }); + } + + private function normalizeDomain(mixed $domain, int $tenantId): ?string + { + if ($domain === null) { + return null; + } + + if (! is_string($domain)) { + throw new RuntimeException("Cannot normalize tenant domain for tenant {$tenantId}."); + } + + $decodedDomain = trim(urldecode($domain)); + + if ($decodedDomain === '') { + return null; + } + + $candidate = str_contains($decodedDomain, '://') + ? $decodedDomain + : "//{$decodedDomain}"; + + $host = parse_url($candidate, PHP_URL_HOST); + + if (! is_string($host) || $host === '') { + throw new RuntimeException( + "Cannot normalize tenant domain '{$domain}' for tenant {$tenantId}." + ); + } + + return strtolower($host); + } +}; diff --git a/tests/Feature/Tenant/BootstrapTenantControllerTest.php b/tests/Feature/Tenant/BootstrapTenantControllerTest.php new file mode 100644 index 0000000..4c749dc --- /dev/null +++ b/tests/Feature/Tenant/BootstrapTenantControllerTest.php @@ -0,0 +1,122 @@ + 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.com', + ]); + + Tenant::createProp([ + 'codigo' => 'primary_color', + 'nombre' => 'Primary Color', + 'is_required' => false, + 'data_type' => 'string', + ]); + + $tenant->setPropValue('primary_color', 'blue'); + + $response = $this->getJson('/api/tenants/bootstrap/acme.com'); + + $response + ->assertOk() + ->assertJsonPath('codigo', 'acme') + ->assertJsonPath('dominio', 'acme.com') + ->assertJsonPath('props.primary_color', 'blue'); + } + + public function test_it_bootstraps_a_tenant_from_a_full_url(): void + { + Tenant::create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.com', + ]); + + $encodedDomain = urlencode('https://ACME.com:8080/path?foo=bar#frag'); + + $response = $this->getJson("/api/tenants/bootstrap/{$encodedDomain}"); + + $response + ->assertOk() + ->assertJsonPath('codigo', 'acme') + ->assertJsonPath('dominio', 'acme.com'); + } + + public function test_it_returns_not_found_when_the_domain_does_not_exist(): void + { + $response = $this->getJson('/api/tenants/bootstrap/missing.example'); + + $response->assertNotFound(); + } + + public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void + { + $firstResponse = $this->postJson('/api/tenants', [ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'https://ACME.com/path', + ]); + + $firstResponse + ->assertCreated() + ->assertJsonPath('dominio', 'acme.com'); + + $secondResponse = $this->postJson('/api/tenants', [ + 'codigo' => 'globex', + 'nombre' => 'Globex', + 'dominio' => 'acme.com', + ]); + + $secondResponse + ->assertUnprocessable() + ->assertJsonValidationErrors(['dominio']); + } + + public function test_it_allows_keeping_the_same_domain_on_update_but_rejects_collisions(): void + { + $tenant = Tenant::create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.com', + ]); + + $otherTenant = Tenant::create([ + 'codigo' => 'globex', + 'nombre' => 'Globex', + 'dominio' => 'globex.com', + ]); + + $successfulResponse = $this->putJson("/api/tenants/{$tenant->id}", [ + 'codigo' => 'acme', + 'nombre' => 'Acme Updated', + 'dominio' => 'https://ACME.com:443/admin', + ]); + + $successfulResponse + ->assertOk() + ->assertJsonPath('nombre', 'Acme Updated') + ->assertJsonPath('dominio', 'acme.com'); + + $failingResponse = $this->putJson("/api/tenants/{$otherTenant->id}", [ + 'codigo' => 'globex', + 'nombre' => 'Globex', + 'dominio' => 'https://ACME.com/storefront', + ]); + + $failingResponse + ->assertUnprocessable() + ->assertJsonValidationErrors(['dominio']); + } +}