feat: separate tenant domain and base path, update related models, requests, and tests

This commit is contained in:
ncoronel 2026-08-18 17:23:01 -03:00
parent 8e26611097
commit a8973f6171
14 changed files with 355 additions and 35 deletions

View File

@ -155,16 +155,19 @@ class GoogleAuthService
$parts = parse_url($returnUrl);
if (! is_array($parts)
|| ! isset($parts['scheme'], $parts['host'])
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])
|| ($parts['path'] ?? '') !== '') {
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])) {
return false;
}
$scheme = strtolower($parts['scheme']);
$host = TenantDomainNormalizer::normalize($parts['host']);
$tenantDomain = TenantDomainNormalizer::normalize($tenant->dominio);
$returnPath = TenantDomainNormalizer::normalizePath($parts['path'] ?? '/');
if ($host === null || $tenantDomain === null || $host !== $tenantDomain) {
if ($host === null
|| $tenantDomain === null
|| $host !== $tenantDomain
|| $returnPath !== $tenant->base_path) {
return false;
}

View File

@ -14,14 +14,15 @@ class TenantBootstrapService
public function get(string $domain, string $path = '/'): Tenant
{
$candidateKeys = TenantDomainNormalizer::tenantKeyCandidates($domain, $path);
$tenantsByDomain = Tenant::query()
->whereIn('dominio', $candidateKeys)
$candidateBasePaths = TenantDomainNormalizer::basePathCandidates($path);
$tenantsByBasePath = Tenant::query()
->where('dominio', $domain)
->whereIn('base_path', $candidateBasePaths)
->get()
->keyBy('dominio');
->keyBy('base_path');
$tenant = collect($candidateKeys)
->map(fn (string $candidate): ?Tenant => $tenantsByDomain->get($candidate))
$tenant = collect($candidateBasePaths)
->map(fn (string $candidate): ?Tenant => $tenantsByBasePath->get($candidate))
->first(fn (?Tenant $candidate): bool => $candidate !== null);
if (! $tenant instanceof Tenant) {

View File

@ -20,6 +20,7 @@ class ClientResource extends JsonResource
'codigo' => $tenant->codigo,
'nombre' => $tenant->nombre,
'dominio' => $tenant->dominio,
'base_path' => $tenant->base_path,
])),
];
}

View File

@ -61,6 +61,10 @@ class NotificationMailService
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
default => $tenant->dominio,
};
$recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
&& $tenant->base_path !== '/'
? $tenant->base_path
: '';
$recoveryQuery = ['email' => $attempt->user->email];
if (
$channel === PasswordResetRequested::CHANNEL_SCANNER
@ -70,7 +74,7 @@ class NotificationMailService
}
$recoveryUrl = $recoveryDomain === null
? null
: 'https://'.$recoveryDomain.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
$this->mailService
->forTenant($tenantCode)

View File

@ -24,6 +24,7 @@ use Illuminate\Support\Facades\Schema;
'codigo',
'nombre',
'dominio',
'base_path',
'site_title',
'primary_color',
'secondary_color',
@ -55,6 +56,7 @@ class Tenant extends Model
use HasFactory;
protected $attributes = [
'base_path' => '/',
'search_product_layout' => ProductLayout::ColumnWithImage->value,
'search_group_layout' => GroupLayout::Paginated->value,
'search_items_per_page' => 12,

View File

@ -15,6 +15,8 @@ class StoreTenantRequest extends FormRequest
{
protected bool $hasInvalidDomain = false;
protected bool $hasInvalidBasePath = false;
public function authorize(): bool
{
return true;
@ -23,13 +25,21 @@ class StoreTenantRequest extends FormRequest
protected function prepareForValidation(): void
{
$rawDomain = $this->input('dominio');
$normalizedDomain = TenantDomainNormalizer::normalizeTenantKey($rawDomain);
$hasExplicitBasePath = $this->has('base_path');
$rawBasePath = $hasExplicitBasePath
? $this->input('base_path')
: TenantDomainNormalizer::pathFromDomain($rawDomain);
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
$normalizedBasePath = TenantDomainNormalizer::normalizePath($rawBasePath);
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
&& $normalizedDomain === null;
$this->hasInvalidBasePath = ($hasExplicitBasePath && ! is_string($rawBasePath))
|| $normalizedBasePath === null;
$this->merge([
'dominio' => $normalizedDomain,
'base_path' => $normalizedBasePath,
]);
}
@ -54,7 +64,21 @@ class StoreTenantRequest extends FormRequest
'required',
'string',
'max:255',
Rule::unique('tenants', 'dominio'),
Rule::unique('tenants', 'dominio')
->where('base_path', $this->input('base_path')),
],
'base_path' => [
'bail',
function (string $attribute, mixed $value, Closure $fail): void {
if ($this->hasInvalidBasePath) {
$fail("The {$attribute} field must contain a valid URL path.");
}
},
'required',
'string',
'max:255',
Rule::unique('tenants', 'base_path')
->where('dominio', $this->input('dominio')),
],
'site_title' => ['sometimes', 'nullable', 'string', 'max:255'],
'primary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],

View File

@ -15,6 +15,8 @@ class UpdateTenantRequest extends FormRequest
{
protected bool $hasInvalidDomain = false;
protected bool $hasInvalidBasePath = false;
public function authorize(): bool
{
return true;
@ -24,14 +26,29 @@ class UpdateTenantRequest extends FormRequest
{
if ($this->has('dominio')) {
$rawDomain = $this->input('dominio');
$normalizedDomain = TenantDomainNormalizer::normalizeTenantKey($rawDomain);
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
$embeddedBasePath = TenantDomainNormalizer::pathFromDomain($rawDomain);
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
&& $normalizedDomain === null;
&& ($normalizedDomain === null || $embeddedBasePath === null);
$this->merge([
'dominio' => $normalizedDomain,
]);
if (! $this->has('base_path') && $embeddedBasePath !== null && $embeddedBasePath !== '/') {
$this->merge(['base_path' => $embeddedBasePath]);
}
}
if ($this->has('base_path')) {
$rawBasePath = $this->input('base_path');
$normalizedBasePath = TenantDomainNormalizer::normalizePath($rawBasePath);
$this->hasInvalidBasePath = ! is_string($rawBasePath)
|| $normalizedBasePath === null;
$this->merge(['base_path' => $normalizedBasePath]);
}
}
@ -42,6 +59,8 @@ class UpdateTenantRequest extends FormRequest
{
/** @var Tenant|null $tenant */
$tenant = $this->route('tenant');
$domain = $this->input('dominio', $tenant?->dominio);
$basePath = $this->input('base_path', $tenant?->base_path ?? '/');
$logoRule = ['nullable', new ImageOrBase64Rule];
@ -64,7 +83,23 @@ class UpdateTenantRequest extends FormRequest
'nullable',
'string',
'max:255',
Rule::unique('tenants', 'dominio')->ignore($tenant?->id),
Rule::unique('tenants', 'dominio')
->where('base_path', $basePath)
->ignore($tenant?->id),
],
'base_path' => [
'bail',
function (string $attribute, mixed $value, Closure $fail): void {
if ($this->hasInvalidBasePath) {
$fail("The {$attribute} field must contain a valid URL path.");
}
},
'nullable',
'string',
'max:255',
Rule::unique('tenants', 'base_path')
->where('dominio', $domain)
->ignore($tenant?->id),
],
'site_title' => ['sometimes', 'nullable', 'string', 'max:255'],
'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],

View File

@ -28,6 +28,7 @@ class TenantResource extends JsonResource
'codigo' => $this->codigo,
'nombre' => $this->nombre,
'dominio' => $this->dominio,
'base_path' => $this->base_path,
'site_title' => $this->site_title
?? $this->websiteType?->site_title
?? 'ShopitFront',

View File

@ -42,13 +42,7 @@ class TenantDomainNormalizer
return null;
}
if ($path === null && is_string($domain)) {
$decodedDomain = trim(urldecode($domain));
$candidate = str_contains($decodedDomain, '://')
? $decodedDomain
: "//{$decodedDomain}";
$path = parse_url($candidate, PHP_URL_PATH) ?: '/';
}
$path ??= self::pathFromDomain($domain);
$normalizedPath = self::normalizePath($path);
@ -59,6 +53,25 @@ class TenantDomainNormalizer
return $host.($normalizedPath === '/' ? '' : $normalizedPath);
}
public static function pathFromDomain(mixed $domain): ?string
{
if (! is_string($domain)) {
return null;
}
$decodedDomain = trim(urldecode($domain));
if ($decodedDomain === '') {
return null;
}
$candidate = str_contains($decodedDomain, '://')
? $decodedDomain
: "//{$decodedDomain}";
return self::normalizePath(parse_url($candidate, PHP_URL_PATH) ?: '/');
}
public static function normalizePath(mixed $path): ?string
{
if (! is_string($path)) {
@ -98,9 +111,23 @@ class TenantDomainNormalizer
public static function tenantKeyCandidates(mixed $domain, mixed $path): array
{
$host = self::normalize($domain);
if ($host === null) {
return [];
}
return array_map(
static fn (string $basePath): string => $host.($basePath === '/' ? '' : $basePath),
self::basePathCandidates($path),
);
}
/** @return list<string> */
public static function basePathCandidates(mixed $path): array
{
$normalizedPath = self::normalizePath($path);
if ($host === null || $normalizedPath === null) {
if ($normalizedPath === null) {
return [];
}
@ -111,11 +138,11 @@ class TenantDomainNormalizer
$candidates = [];
while ($segments !== []) {
$candidates[] = $host.'/'.implode('/', $segments);
$candidates[] = '/'.implode('/', $segments);
array_pop($segments);
}
$candidates[] = $host;
$candidates[] = '/';
return $candidates;
}

View File

@ -6,7 +6,7 @@ Es la raíz del modelo multi-tenant. Gestiona organizaciones/sitios, tipos de we
## Modelo
- `Tenant`: entidad principal, resuelta en rutas por `codigo`; relaciona catálogo, fechas, redes, menús y configuración visual.
- `Tenant`: entidad principal, resuelta en rutas por `codigo`; relaciona catálogo, fechas, redes, menús y configuración visual. Su ubicación pública se representa con `dominio` y `base_path` (`/` para la raíz).
- `WebsiteType`: plantilla o tipo de sitio disponible.
- `WebsiteTypeExtra`: definición de un extra y su configuración admitida.
- `WebsiteExtra`: valor resuelto y estado del extra para un tenant.
@ -20,6 +20,8 @@ Es la raíz del modelo multi-tenant. Gestiona organizaciones/sitios, tipos de we
- `WebsiteExtraService`: construye reglas dinámicas, crea, actualiza y habilita/deshabilita extras.
- `TenantDomainNormalizer`: normaliza dominios antes de resolver el tenant.
El par `(dominio, base_path)` es único. Un mismo dominio puede alojar el tenant raíz y otros tenants en prefijos diferentes. El bootstrap compara segmentos completos del path y selecciona el prefijo más específico.
## Endpoints
- Recurso REST público/administrativo `/tenants`.

View File

@ -0,0 +1,109 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$locations = DB::table('tenants')
->select(['id', 'dominio'])
->orderBy('id')
->get()
->mapWithKeys(function (object $tenant): array {
[$domain, $basePath] = $this->splitTenantLocation($tenant->dominio, $tenant->id);
return [$tenant->id => compact('domain', 'basePath')];
});
$duplicates = $locations
->groupBy(fn (array $location): string => $location['domain'].'|'.$location['basePath'])
->filter(fn ($matches): bool => $matches->count() > 1);
if ($duplicates->isNotEmpty()) {
throw new RuntimeException(
'Cannot create the tenant domain/base-path unique index; duplicates exist: '
.$duplicates->keys()->implode(', ')
);
}
Schema::table('tenants', function (Blueprint $table) {
$table->string('base_path')->default('/')->after('dominio');
$table->dropUnique('tenants_dominio_unique');
});
foreach ($locations as $tenantId => $location) {
DB::table('tenants')
->where('id', $tenantId)
->update([
'dominio' => $location['domain'],
'base_path' => $location['basePath'],
]);
}
Schema::table('tenants', function (Blueprint $table) {
$table->unique(
['dominio', 'base_path'],
'tenants_dominio_base_path_unique',
);
});
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropUnique('tenants_dominio_base_path_unique');
});
DB::table('tenants')
->select(['id', 'dominio', 'base_path'])
->orderBy('id')
->each(function (object $tenant): void {
$tenantKey = $tenant->dominio.($tenant->base_path === '/' ? '' : $tenant->base_path);
DB::table('tenants')
->where('id', $tenant->id)
->update(['dominio' => $tenantKey]);
});
Schema::table('tenants', function (Blueprint $table) {
$table->dropColumn('base_path');
$table->unique('dominio', 'tenants_dominio_unique');
});
}
/** @return array{string, string} */
private function splitTenantLocation(mixed $value, int $tenantId): array
{
if (! is_string($value) || trim(urldecode($value)) === '') {
throw new RuntimeException("Cannot migrate tenant location for tenant {$tenantId}.");
}
$decodedValue = trim(urldecode($value));
$candidate = str_contains($decodedValue, '://')
? $decodedValue
: "//{$decodedValue}";
$host = parse_url($candidate, PHP_URL_HOST);
$path = parse_url($candidate, PHP_URL_PATH) ?: '/';
if (! is_string($host) || $host === '') {
throw new RuntimeException("Cannot migrate tenant location '{$value}' for tenant {$tenantId}.");
}
$segments = array_values(array_filter(
explode('/', preg_replace('#/+#', '/', $path) ?? ''),
static fn (string $segment): bool => $segment !== '',
));
if (array_intersect($segments, ['.', '..']) !== []) {
throw new RuntimeException("Cannot migrate tenant base path '{$path}' for tenant {$tenantId}.");
}
return [
strtolower($host),
$segments === [] ? '/' : '/'.implode('/', $segments),
];
}
};

View File

@ -0,0 +1,92 @@
<?php
namespace Tests\Feature\Migrations;
use Illuminate\Database\QueryException;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class SeparateTenantDomainAndBasePathTest extends TestCase
{
private string $originalConnection;
protected function setUp(): void
{
parent::setUp();
$this->originalConnection = DB::getDefaultConnection();
config()->set('database.connections.tenant_path_test', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
'foreign_key_constraints' => true,
]);
DB::setDefaultConnection('tenant_path_test');
Schema::create('tenants', function (Blueprint $table): void {
$table->id();
$table->string('dominio');
$table->unique('dominio', 'tenants_dominio_unique');
});
}
protected function tearDown(): void
{
DB::purge('tenant_path_test');
DB::setDefaultConnection($this->originalConnection);
parent::tearDown();
}
public function test_it_splits_existing_tenant_keys_and_enforces_composite_uniqueness(): void
{
DB::table('tenants')->insert([
['dominio' => 'onticket.com.ar'],
['dominio' => 'onticket.com.ar/desfile/'],
['dominio' => 'https://ONTICKET.COM.AR/sonder'],
]);
$migration = require database_path(
'migrations/2026_08_18_060000_separate_tenant_domain_and_base_path.php'
);
$migration->up();
$this->assertDatabaseHas('tenants', [
'dominio' => 'onticket.com.ar',
'base_path' => '/',
]);
$this->assertDatabaseHas('tenants', [
'dominio' => 'onticket.com.ar',
'base_path' => '/desfile',
]);
$this->assertDatabaseHas('tenants', [
'dominio' => 'onticket.com.ar',
'base_path' => '/sonder',
]);
$this->expectException(QueryException::class);
DB::table('tenants')->insert([
'dominio' => 'onticket.com.ar',
'base_path' => '/desfile',
]);
}
public function test_it_recombines_tenant_locations_when_rolled_back(): void
{
DB::table('tenants')->insert(['dominio' => 'onticket.com.ar/desfile']);
$migration = require database_path(
'migrations/2026_08_18_060000_separate_tenant_domain_and_base_path.php'
);
$migration->up();
$migration->down();
$this->assertFalse(Schema::hasColumn('tenants', 'base_path'));
$this->assertDatabaseHas('tenants', [
'dominio' => 'onticket.com.ar/desfile',
]);
}
}

View File

@ -142,23 +142,27 @@ class BootstrapTenantControllerTest extends TestCase
$this->createTenant([
'codigo' => 'pura-tendencia',
'nombre' => 'Pura Tendencia',
'dominio' => 'qa.onticket.com.ar/puratendencia',
'dominio' => 'qa.onticket.com.ar',
'base_path' => '/puratendencia',
]);
$this->createTenant([
'codigo' => 'pura-tendencia-vip',
'nombre' => 'Pura Tendencia VIP',
'dominio' => 'qa.onticket.com.ar/puratendencia/vip',
'dominio' => 'qa.onticket.com.ar',
'base_path' => '/puratendencia/vip',
]);
$this->getJson('/api/tenants/bootstrap?dominio=qa.onticket.com.ar&path=%2Fpuratendencia%2Fproductos%2F123')
->assertOk()
->assertJsonPath('data.codigo', 'pura-tendencia')
->assertJsonPath('data.dominio', 'qa.onticket.com.ar/puratendencia');
->assertJsonPath('data.dominio', 'qa.onticket.com.ar')
->assertJsonPath('data.base_path', '/puratendencia');
$this->getJson('/api/tenants/bootstrap?dominio=qa.onticket.com.ar&path=%2Fpuratendencia%2Fvip%2Fproductos%2F123')
->assertOk()
->assertJsonPath('data.codigo', 'pura-tendencia-vip')
->assertJsonPath('data.dominio', 'qa.onticket.com.ar/puratendencia/vip');
->assertJsonPath('data.dominio', 'qa.onticket.com.ar')
->assertJsonPath('data.base_path', '/puratendencia/vip');
}
public function test_it_uses_the_root_tenant_for_spa_paths_without_a_tenant_prefix(): void
@ -171,7 +175,8 @@ class BootstrapTenantControllerTest extends TestCase
$this->createTenant([
'codigo' => 'sonder',
'nombre' => 'Sonder',
'dominio' => 'qa.onticket.com.ar/sonder',
'dominio' => 'qa.onticket.com.ar',
'base_path' => '/sonder',
]);
$this->getJson('/api/tenants/bootstrap?dominio=qa.onticket.com.ar&path=%2Fproducto%2F123')
@ -574,7 +579,8 @@ class BootstrapTenantControllerTest extends TestCase
$firstResponse
->assertCreated()
->assertJsonPath('data.dominio', 'acme.com/puratendencia')
->assertJsonPath('data.dominio', 'acme.com')
->assertJsonPath('data.base_path', '/puratendencia')
->assertJsonPath('data.primary_color', '#111111')
->assertJsonPath('data.secondary_color', '#222222')
->assertJsonPath('data.danger_color', '#333333')
@ -613,7 +619,8 @@ class BootstrapTenantControllerTest extends TestCase
$differentPathResponse = $this->postJson('/api/tenants', [
'codigo' => 'pura-tendencia',
'nombre' => 'Pura Tendencia',
'dominio' => 'acme.com/sonder',
'dominio' => 'acme.com',
'base_path' => '/sonder/',
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
@ -626,12 +633,14 @@ class BootstrapTenantControllerTest extends TestCase
$differentPathResponse
->assertCreated()
->assertJsonPath('data.dominio', 'acme.com/sonder');
->assertJsonPath('data.dominio', 'acme.com')
->assertJsonPath('data.base_path', '/sonder');
$secondResponse = $this->postJson('/api/tenants', [
'codigo' => 'globex',
'nombre' => 'Globex',
'dominio' => 'acme.com/puratendencia',
'dominio' => 'acme.com',
'base_path' => '/puratendencia',
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',

View File

@ -73,4 +73,14 @@ class TenantDomainNormalizerTest extends TestCase
'/sonder-shop/productos',
));
}
public function test_it_builds_base_path_candidates_from_the_longest_path_to_root(): void
{
$this->assertSame([
'/desfile/productos/123',
'/desfile/productos',
'/desfile',
'/',
], TenantDomainNormalizer::basePathCandidates('/desfile/productos/123?ref=home'));
}
}