feat: separate tenant domain and base path, update related models, requests, and tests
This commit is contained in:
@@ -155,16 +155,19 @@ class GoogleAuthService
|
|||||||
$parts = parse_url($returnUrl);
|
$parts = parse_url($returnUrl);
|
||||||
if (! is_array($parts)
|
if (! is_array($parts)
|
||||||
|| ! isset($parts['scheme'], $parts['host'])
|
|| ! isset($parts['scheme'], $parts['host'])
|
||||||
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])
|
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])) {
|
||||||
|| ($parts['path'] ?? '') !== '') {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$scheme = strtolower($parts['scheme']);
|
$scheme = strtolower($parts['scheme']);
|
||||||
$host = TenantDomainNormalizer::normalize($parts['host']);
|
$host = TenantDomainNormalizer::normalize($parts['host']);
|
||||||
$tenantDomain = TenantDomainNormalizer::normalize($tenant->dominio);
|
$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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,15 @@ class TenantBootstrapService
|
|||||||
|
|
||||||
public function get(string $domain, string $path = '/'): Tenant
|
public function get(string $domain, string $path = '/'): Tenant
|
||||||
{
|
{
|
||||||
$candidateKeys = TenantDomainNormalizer::tenantKeyCandidates($domain, $path);
|
$candidateBasePaths = TenantDomainNormalizer::basePathCandidates($path);
|
||||||
$tenantsByDomain = Tenant::query()
|
$tenantsByBasePath = Tenant::query()
|
||||||
->whereIn('dominio', $candidateKeys)
|
->where('dominio', $domain)
|
||||||
|
->whereIn('base_path', $candidateBasePaths)
|
||||||
->get()
|
->get()
|
||||||
->keyBy('dominio');
|
->keyBy('base_path');
|
||||||
|
|
||||||
$tenant = collect($candidateKeys)
|
$tenant = collect($candidateBasePaths)
|
||||||
->map(fn (string $candidate): ?Tenant => $tenantsByDomain->get($candidate))
|
->map(fn (string $candidate): ?Tenant => $tenantsByBasePath->get($candidate))
|
||||||
->first(fn (?Tenant $candidate): bool => $candidate !== null);
|
->first(fn (?Tenant $candidate): bool => $candidate !== null);
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class ClientResource extends JsonResource
|
|||||||
'codigo' => $tenant->codigo,
|
'codigo' => $tenant->codigo,
|
||||||
'nombre' => $tenant->nombre,
|
'nombre' => $tenant->nombre,
|
||||||
'dominio' => $tenant->dominio,
|
'dominio' => $tenant->dominio,
|
||||||
|
'base_path' => $tenant->base_path,
|
||||||
])),
|
])),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ class NotificationMailService
|
|||||||
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
|
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
|
||||||
default => $tenant->dominio,
|
default => $tenant->dominio,
|
||||||
};
|
};
|
||||||
|
$recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
|
||||||
|
&& $tenant->base_path !== '/'
|
||||||
|
? $tenant->base_path
|
||||||
|
: '';
|
||||||
$recoveryQuery = ['email' => $attempt->user->email];
|
$recoveryQuery = ['email' => $attempt->user->email];
|
||||||
if (
|
if (
|
||||||
$channel === PasswordResetRequested::CHANNEL_SCANNER
|
$channel === PasswordResetRequested::CHANNEL_SCANNER
|
||||||
@@ -70,7 +74,7 @@ class NotificationMailService
|
|||||||
}
|
}
|
||||||
$recoveryUrl = $recoveryDomain === null
|
$recoveryUrl = $recoveryDomain === null
|
||||||
? null
|
? null
|
||||||
: 'https://'.$recoveryDomain.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
|
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
|
||||||
|
|
||||||
$this->mailService
|
$this->mailService
|
||||||
->forTenant($tenantCode)
|
->forTenant($tenantCode)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use Illuminate\Support\Facades\Schema;
|
|||||||
'codigo',
|
'codigo',
|
||||||
'nombre',
|
'nombre',
|
||||||
'dominio',
|
'dominio',
|
||||||
|
'base_path',
|
||||||
'site_title',
|
'site_title',
|
||||||
'primary_color',
|
'primary_color',
|
||||||
'secondary_color',
|
'secondary_color',
|
||||||
@@ -55,6 +56,7 @@ class Tenant extends Model
|
|||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
|
'base_path' => '/',
|
||||||
'search_product_layout' => ProductLayout::ColumnWithImage->value,
|
'search_product_layout' => ProductLayout::ColumnWithImage->value,
|
||||||
'search_group_layout' => GroupLayout::Paginated->value,
|
'search_group_layout' => GroupLayout::Paginated->value,
|
||||||
'search_items_per_page' => 12,
|
'search_items_per_page' => 12,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class StoreTenantRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
protected bool $hasInvalidDomain = false;
|
protected bool $hasInvalidDomain = false;
|
||||||
|
|
||||||
|
protected bool $hasInvalidBasePath = false;
|
||||||
|
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
@@ -23,13 +25,21 @@ class StoreTenantRequest extends FormRequest
|
|||||||
protected function prepareForValidation(): void
|
protected function prepareForValidation(): void
|
||||||
{
|
{
|
||||||
$rawDomain = $this->input('dominio');
|
$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)
|
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
||||||
&& $normalizedDomain === null;
|
&& $normalizedDomain === null;
|
||||||
|
$this->hasInvalidBasePath = ($hasExplicitBasePath && ! is_string($rawBasePath))
|
||||||
|
|| $normalizedBasePath === null;
|
||||||
|
|
||||||
$this->merge([
|
$this->merge([
|
||||||
'dominio' => $normalizedDomain,
|
'dominio' => $normalizedDomain,
|
||||||
|
'base_path' => $normalizedBasePath,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +64,21 @@ class StoreTenantRequest extends FormRequest
|
|||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
'max:255',
|
'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'],
|
'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})$/'],
|
'primary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class UpdateTenantRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
protected bool $hasInvalidDomain = false;
|
protected bool $hasInvalidDomain = false;
|
||||||
|
|
||||||
|
protected bool $hasInvalidBasePath = false;
|
||||||
|
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
@@ -24,14 +26,29 @@ class UpdateTenantRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
if ($this->has('dominio')) {
|
if ($this->has('dominio')) {
|
||||||
$rawDomain = $this->input('dominio');
|
$rawDomain = $this->input('dominio');
|
||||||
$normalizedDomain = TenantDomainNormalizer::normalizeTenantKey($rawDomain);
|
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
|
||||||
|
$embeddedBasePath = TenantDomainNormalizer::pathFromDomain($rawDomain);
|
||||||
|
|
||||||
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
||||||
&& $normalizedDomain === null;
|
&& ($normalizedDomain === null || $embeddedBasePath === null);
|
||||||
|
|
||||||
$this->merge([
|
$this->merge([
|
||||||
'dominio' => $normalizedDomain,
|
'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 */
|
/** @var Tenant|null $tenant */
|
||||||
$tenant = $this->route('tenant');
|
$tenant = $this->route('tenant');
|
||||||
|
$domain = $this->input('dominio', $tenant?->dominio);
|
||||||
|
$basePath = $this->input('base_path', $tenant?->base_path ?? '/');
|
||||||
|
|
||||||
$logoRule = ['nullable', new ImageOrBase64Rule];
|
$logoRule = ['nullable', new ImageOrBase64Rule];
|
||||||
|
|
||||||
@@ -64,7 +83,23 @@ class UpdateTenantRequest extends FormRequest
|
|||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
'max:255',
|
'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'],
|
'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})$/'],
|
'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class TenantResource extends JsonResource
|
|||||||
'codigo' => $this->codigo,
|
'codigo' => $this->codigo,
|
||||||
'nombre' => $this->nombre,
|
'nombre' => $this->nombre,
|
||||||
'dominio' => $this->dominio,
|
'dominio' => $this->dominio,
|
||||||
|
'base_path' => $this->base_path,
|
||||||
'site_title' => $this->site_title
|
'site_title' => $this->site_title
|
||||||
?? $this->websiteType?->site_title
|
?? $this->websiteType?->site_title
|
||||||
?? 'ShopitFront',
|
?? 'ShopitFront',
|
||||||
|
|||||||
@@ -42,13 +42,7 @@ class TenantDomainNormalizer
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($path === null && is_string($domain)) {
|
$path ??= self::pathFromDomain($domain);
|
||||||
$decodedDomain = trim(urldecode($domain));
|
|
||||||
$candidate = str_contains($decodedDomain, '://')
|
|
||||||
? $decodedDomain
|
|
||||||
: "//{$decodedDomain}";
|
|
||||||
$path = parse_url($candidate, PHP_URL_PATH) ?: '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalizedPath = self::normalizePath($path);
|
$normalizedPath = self::normalizePath($path);
|
||||||
|
|
||||||
@@ -59,6 +53,25 @@ class TenantDomainNormalizer
|
|||||||
return $host.($normalizedPath === '/' ? '' : $normalizedPath);
|
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
|
public static function normalizePath(mixed $path): ?string
|
||||||
{
|
{
|
||||||
if (! is_string($path)) {
|
if (! is_string($path)) {
|
||||||
@@ -98,9 +111,23 @@ class TenantDomainNormalizer
|
|||||||
public static function tenantKeyCandidates(mixed $domain, mixed $path): array
|
public static function tenantKeyCandidates(mixed $domain, mixed $path): array
|
||||||
{
|
{
|
||||||
$host = self::normalize($domain);
|
$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);
|
$normalizedPath = self::normalizePath($path);
|
||||||
|
|
||||||
if ($host === null || $normalizedPath === null) {
|
if ($normalizedPath === null) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,11 +138,11 @@ class TenantDomainNormalizer
|
|||||||
$candidates = [];
|
$candidates = [];
|
||||||
|
|
||||||
while ($segments !== []) {
|
while ($segments !== []) {
|
||||||
$candidates[] = $host.'/'.implode('/', $segments);
|
$candidates[] = '/'.implode('/', $segments);
|
||||||
array_pop($segments);
|
array_pop($segments);
|
||||||
}
|
}
|
||||||
|
|
||||||
$candidates[] = $host;
|
$candidates[] = '/';
|
||||||
|
|
||||||
return $candidates;
|
return $candidates;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Es la raíz del modelo multi-tenant. Gestiona organizaciones/sitios, tipos de we
|
|||||||
|
|
||||||
## Modelo
|
## 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.
|
- `WebsiteType`: plantilla o tipo de sitio disponible.
|
||||||
- `WebsiteTypeExtra`: definición de un extra y su configuración admitida.
|
- `WebsiteTypeExtra`: definición de un extra y su configuración admitida.
|
||||||
- `WebsiteExtra`: valor resuelto y estado del extra para un tenant.
|
- `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.
|
- `WebsiteExtraService`: construye reglas dinámicas, crea, actualiza y habilita/deshabilita extras.
|
||||||
- `TenantDomainNormalizer`: normaliza dominios antes de resolver el tenant.
|
- `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
|
## Endpoints
|
||||||
|
|
||||||
- Recurso REST público/administrativo `/tenants`.
|
- Recurso REST público/administrativo `/tenants`.
|
||||||
|
|||||||
@@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,23 +142,27 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
$this->createTenant([
|
$this->createTenant([
|
||||||
'codigo' => 'pura-tendencia',
|
'codigo' => 'pura-tendencia',
|
||||||
'nombre' => 'Pura Tendencia',
|
'nombre' => 'Pura Tendencia',
|
||||||
'dominio' => 'qa.onticket.com.ar/puratendencia',
|
'dominio' => 'qa.onticket.com.ar',
|
||||||
|
'base_path' => '/puratendencia',
|
||||||
]);
|
]);
|
||||||
$this->createTenant([
|
$this->createTenant([
|
||||||
'codigo' => 'pura-tendencia-vip',
|
'codigo' => 'pura-tendencia-vip',
|
||||||
'nombre' => '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')
|
$this->getJson('/api/tenants/bootstrap?dominio=qa.onticket.com.ar&path=%2Fpuratendencia%2Fproductos%2F123')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.codigo', 'pura-tendencia')
|
->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')
|
$this->getJson('/api/tenants/bootstrap?dominio=qa.onticket.com.ar&path=%2Fpuratendencia%2Fvip%2Fproductos%2F123')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.codigo', 'pura-tendencia-vip')
|
->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
|
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([
|
$this->createTenant([
|
||||||
'codigo' => 'sonder',
|
'codigo' => 'sonder',
|
||||||
'nombre' => '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')
|
$this->getJson('/api/tenants/bootstrap?dominio=qa.onticket.com.ar&path=%2Fproducto%2F123')
|
||||||
@@ -574,7 +579,8 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
|
|
||||||
$firstResponse
|
$firstResponse
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->assertJsonPath('data.dominio', 'acme.com/puratendencia')
|
->assertJsonPath('data.dominio', 'acme.com')
|
||||||
|
->assertJsonPath('data.base_path', '/puratendencia')
|
||||||
->assertJsonPath('data.primary_color', '#111111')
|
->assertJsonPath('data.primary_color', '#111111')
|
||||||
->assertJsonPath('data.secondary_color', '#222222')
|
->assertJsonPath('data.secondary_color', '#222222')
|
||||||
->assertJsonPath('data.danger_color', '#333333')
|
->assertJsonPath('data.danger_color', '#333333')
|
||||||
@@ -613,7 +619,8 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
$differentPathResponse = $this->postJson('/api/tenants', [
|
$differentPathResponse = $this->postJson('/api/tenants', [
|
||||||
'codigo' => 'pura-tendencia',
|
'codigo' => 'pura-tendencia',
|
||||||
'nombre' => 'Pura Tendencia',
|
'nombre' => 'Pura Tendencia',
|
||||||
'dominio' => 'acme.com/sonder',
|
'dominio' => 'acme.com',
|
||||||
|
'base_path' => '/sonder/',
|
||||||
'primary_color' => '#111111',
|
'primary_color' => '#111111',
|
||||||
'secondary_color' => '#222222',
|
'secondary_color' => '#222222',
|
||||||
'danger_color' => '#333333',
|
'danger_color' => '#333333',
|
||||||
@@ -626,12 +633,14 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
|
|
||||||
$differentPathResponse
|
$differentPathResponse
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->assertJsonPath('data.dominio', 'acme.com/sonder');
|
->assertJsonPath('data.dominio', 'acme.com')
|
||||||
|
->assertJsonPath('data.base_path', '/sonder');
|
||||||
|
|
||||||
$secondResponse = $this->postJson('/api/tenants', [
|
$secondResponse = $this->postJson('/api/tenants', [
|
||||||
'codigo' => 'globex',
|
'codigo' => 'globex',
|
||||||
'nombre' => 'Globex',
|
'nombre' => 'Globex',
|
||||||
'dominio' => 'acme.com/puratendencia',
|
'dominio' => 'acme.com',
|
||||||
|
'base_path' => '/puratendencia',
|
||||||
'primary_color' => '#111111',
|
'primary_color' => '#111111',
|
||||||
'secondary_color' => '#222222',
|
'secondary_color' => '#222222',
|
||||||
'danger_color' => '#333333',
|
'danger_color' => '#333333',
|
||||||
|
|||||||
@@ -73,4 +73,14 @@ class TenantDomainNormalizerTest extends TestCase
|
|||||||
'/sonder-shop/productos',
|
'/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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user