Compare commits
9 Commits
d73b4d1daf
...
67e6211857
| Author | SHA1 | Date | |
|---|---|---|---|
| 67e6211857 | |||
| 58471cb13d | |||
| 864bed0113 | |||
| ced7d594e8 | |||
| 38e87fff6c | |||
| a8973f6171 | |||
| 8e26611097 | |||
| 817d0de6d2 | |||
| a2298c9de2 |
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Domains\Cart\Resources;
|
|||||||
|
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -19,12 +20,14 @@ class CartItemResource extends JsonResource
|
|||||||
{
|
{
|
||||||
$selectedItem = $this->selectedItem();
|
$selectedItem = $this->selectedItem();
|
||||||
$imageUrl = null;
|
$imageUrl = null;
|
||||||
|
$tenant = $request->route('tenant');
|
||||||
|
$displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images;
|
||||||
|
|
||||||
if ($selectedItem?->relationLoaded('attachments')) {
|
if ($displayImage && $selectedItem?->relationLoaded('attachments')) {
|
||||||
$imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440);
|
$imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
|
if ($displayImage && $imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
|
||||||
$imageUrl = $this->catalogItem->attachments->first()?->getTemporaryUrl(1440);
|
$imageUrl = $this->catalogItem->attachments->first()?->getTemporaryUrl(1440);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Domains\Cart\Models\CartItem;
|
|||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -15,8 +16,13 @@ class PurchaseItemResource extends JsonResource
|
|||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
|
$tenant = $request->route('tenant');
|
||||||
|
$displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images;
|
||||||
|
|
||||||
if ($this->resource instanceof PurchaseItem) {
|
if ($this->resource instanceof PurchaseItem) {
|
||||||
$imageUrl = $this->imageAttachment?->getTemporaryUrl(1440);
|
$imageUrl = $displayImage
|
||||||
|
? $this->imageAttachment?->getTemporaryUrl(1440)
|
||||||
|
: null;
|
||||||
$attributes = $this->variant_attributes ?? [];
|
$attributes = $this->variant_attributes ?? [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -42,7 +48,9 @@ class PurchaseItemResource extends JsonResource
|
|||||||
$quantity = (int) ($this->cantidad ?? 0);
|
$quantity = (int) ($this->cantidad ?? 0);
|
||||||
$unitPrice = $this->resolveUnitPrice($selectedItem);
|
$unitPrice = $this->resolveUnitPrice($selectedItem);
|
||||||
$lineTotal = $unitPrice * $quantity;
|
$lineTotal = $unitPrice * $quantity;
|
||||||
$imageUrl = $this->resolveImageUrl($selectedItem, $catalogItem);
|
$imageUrl = $displayImage
|
||||||
|
? $this->resolveImageUrl($selectedItem, $catalogItem)
|
||||||
|
: null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -43,6 +44,8 @@ use Illuminate\Support\Facades\Schema;
|
|||||||
'display_categories',
|
'display_categories',
|
||||||
'display_seach_bar',
|
'display_seach_bar',
|
||||||
'display_cart',
|
'display_cart',
|
||||||
|
'cart_editing_enabled',
|
||||||
|
'display_cart_item_images',
|
||||||
'scanner_category_validation_enabled',
|
'scanner_category_validation_enabled',
|
||||||
'event_title',
|
'event_title',
|
||||||
'event_location',
|
'event_location',
|
||||||
@@ -53,12 +56,15 @@ 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,
|
||||||
'display_categories' => true,
|
'display_categories' => true,
|
||||||
'display_seach_bar' => true,
|
'display_seach_bar' => true,
|
||||||
'display_cart' => true,
|
'display_cart' => true,
|
||||||
|
'cart_editing_enabled' => true,
|
||||||
|
'display_cart_item_images' => true,
|
||||||
'scanner_category_validation_enabled' => true,
|
'scanner_category_validation_enabled' => true,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -108,6 +114,8 @@ class Tenant extends Model
|
|||||||
'display_categories' => 'boolean',
|
'display_categories' => 'boolean',
|
||||||
'display_seach_bar' => 'boolean',
|
'display_seach_bar' => 'boolean',
|
||||||
'display_cart' => 'boolean',
|
'display_cart' => 'boolean',
|
||||||
|
'cart_editing_enabled' => 'boolean',
|
||||||
|
'display_cart_item_images' => 'boolean',
|
||||||
'scanner_category_validation_enabled' => 'boolean',
|
'scanner_category_validation_enabled' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})$/'],
|
||||||
@@ -83,6 +107,8 @@ class StoreTenantRequest extends FormRequest
|
|||||||
'display_categories' => ['sometimes', 'boolean'],
|
'display_categories' => ['sometimes', 'boolean'],
|
||||||
'display_seach_bar' => ['sometimes', 'boolean'],
|
'display_seach_bar' => ['sometimes', 'boolean'],
|
||||||
'display_cart' => ['sometimes', 'boolean'],
|
'display_cart' => ['sometimes', 'boolean'],
|
||||||
|
'cart_editing_enabled' => ['sometimes', 'boolean'],
|
||||||
|
'display_cart_item_images' => ['sometimes', 'boolean'],
|
||||||
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
|
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
|
||||||
'website_type_code' => [
|
'website_type_code' => [
|
||||||
'required_with:extras',
|
'required_with:extras',
|
||||||
|
|||||||
@@ -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})$/'],
|
||||||
@@ -93,6 +128,8 @@ class UpdateTenantRequest extends FormRequest
|
|||||||
'display_categories' => ['sometimes', 'boolean'],
|
'display_categories' => ['sometimes', 'boolean'],
|
||||||
'display_seach_bar' => ['sometimes', 'boolean'],
|
'display_seach_bar' => ['sometimes', 'boolean'],
|
||||||
'display_cart' => ['sometimes', 'boolean'],
|
'display_cart' => ['sometimes', 'boolean'],
|
||||||
|
'cart_editing_enabled' => ['sometimes', 'boolean'],
|
||||||
|
'display_cart_item_images' => ['sometimes', 'boolean'],
|
||||||
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
|
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -74,6 +75,8 @@ class TenantResource extends JsonResource
|
|||||||
'display_categories' => $this->display_categories,
|
'display_categories' => $this->display_categories,
|
||||||
'display_seach_bar' => $this->display_seach_bar,
|
'display_seach_bar' => $this->display_seach_bar,
|
||||||
'display_cart' => $this->display_cart,
|
'display_cart' => $this->display_cart,
|
||||||
|
'cart_editing_enabled' => $this->cart_editing_enabled,
|
||||||
|
'display_cart_item_images' => $this->display_cart_item_images,
|
||||||
'scanner_category_validation_enabled' => $this->scanner_category_validation_enabled,
|
'scanner_category_validation_enabled' => $this->scanner_category_validation_enabled,
|
||||||
'social_media' => $this->whenLoaded(
|
'social_media' => $this->whenLoaded(
|
||||||
'socialMedia',
|
'socialMedia',
|
||||||
|
|||||||
@@ -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,29 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
private const DESFILE_TENANT_CODE = 'desfile_pura_tendencia';
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('tenants', function (Blueprint $table): void {
|
||||||
|
$table->boolean('cart_editing_enabled')->default(true)->after('display_cart');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('tenants')
|
||||||
|
->where('codigo', self::DESFILE_TENANT_CODE)
|
||||||
|
->update(['cart_editing_enabled' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tenants', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('cart_editing_enabled');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,29 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
private const DESFILE_TENANT_CODE = 'desfile_pura_tendencia';
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('tenants', function (Blueprint $table): void {
|
||||||
|
$table->boolean('display_cart_item_images')->default(true)->after('cart_editing_enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('tenants')
|
||||||
|
->where('codigo', self::DESFILE_TENANT_CODE)
|
||||||
|
->update(['display_cart_item_images' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tenants', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('display_cart_item_images');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -72,6 +72,8 @@ class DesfilePuraTendenciaSeeder extends Seeder
|
|||||||
'display_categories' => false,
|
'display_categories' => false,
|
||||||
'display_seach_bar' => false,
|
'display_seach_bar' => false,
|
||||||
'display_cart' => true,
|
'display_cart' => true,
|
||||||
|
'cart_editing_enabled' => false,
|
||||||
|
'display_cart_item_images' => false,
|
||||||
'scanner_category_validation_enabled' => false,
|
'scanner_category_validation_enabled' => false,
|
||||||
'website_type_code' => 'onticket',
|
'website_type_code' => 'onticket',
|
||||||
'header_logo' => $this->uploadedImage(
|
'header_logo' => $this->uploadedImage(
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class TenantSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
private const ONTICKET_CLIENT_CODE = 'onticket';
|
private const ONTICKET_CLIENT_CODE = 'onticket';
|
||||||
|
|
||||||
|
private const SONDER_CLIENT_CODE = 'sonder';
|
||||||
|
|
||||||
private const SOCIAL_MEDIA = [
|
private const SOCIAL_MEDIA = [
|
||||||
[
|
[
|
||||||
'code' => 'instagram',
|
'code' => 'instagram',
|
||||||
@@ -43,6 +45,11 @@ class TenantSeeder extends Seeder
|
|||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
|
$sonderClient = Client::query()->firstOrCreate(
|
||||||
|
['code' => self::SONDER_CLIENT_CODE],
|
||||||
|
['name' => 'Sonder'],
|
||||||
|
);
|
||||||
|
|
||||||
$onTicketClient = Client::query()->firstOrCreate(
|
$onTicketClient = Client::query()->firstOrCreate(
|
||||||
['code' => self::ONTICKET_CLIENT_CODE],
|
['code' => self::ONTICKET_CLIENT_CODE],
|
||||||
['name' => 'OnTicket'],
|
['name' => 'OnTicket'],
|
||||||
@@ -55,6 +62,7 @@ class TenantSeeder extends Seeder
|
|||||||
->delete();
|
->delete();
|
||||||
|
|
||||||
$this->tenantService->create([
|
$this->tenantService->create([
|
||||||
|
'client_id' => $sonderClient->id,
|
||||||
'codigo' => 'sonder',
|
'codigo' => 'sonder',
|
||||||
'nombre' => 'Sonder',
|
'nombre' => 'Sonder',
|
||||||
'dominio' => 'localhost',
|
'dominio' => 'localhost',
|
||||||
|
|||||||
451
postman/generate-shopit-collection.php
Normal file
451
postman/generate-shopit-collection.php
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerates the canonical Postman collection from Laravel's registered routes.
|
||||||
|
*
|
||||||
|
* Run from the backend root with:
|
||||||
|
* php postman/generate-shopit-collection.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
chdir($root);
|
||||||
|
|
||||||
|
$command = escapeshellarg(PHP_BINARY).' artisan route:list --path=api --json';
|
||||||
|
$routeJson = shell_exec($command);
|
||||||
|
|
||||||
|
if (! is_string($routeJson) || trim($routeJson) === '') {
|
||||||
|
fwrite(STDERR, "Unable to read Laravel routes.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$routes = json_decode($routeJson, true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
const TINY_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
function jsonBody(array $payload): array
|
||||||
|
{
|
||||||
|
$raw = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
$raw = preg_replace('/"\{\{([a-z_]+_id)\}\}"/', '{{$1}}', $raw);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'mode' => 'raw',
|
||||||
|
'raw' => $raw,
|
||||||
|
'options' => ['raw' => ['language' => 'json']],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, string> $fields
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
function formDataBody(array $fields, array $fileFields = []): array
|
||||||
|
{
|
||||||
|
$formData = [];
|
||||||
|
|
||||||
|
foreach ($fields as $key => $value) {
|
||||||
|
$formData[] = ['key' => $key, 'value' => $value, 'type' => 'text'];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($fileFields as $key) {
|
||||||
|
$formData[] = ['key' => $key, 'type' => 'file', 'src' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['mode' => 'formdata', 'formdata' => $formData];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed>|null */
|
||||||
|
function bodyFor(string $method, string $uri): ?array
|
||||||
|
{
|
||||||
|
$key = $method.' '.$uri;
|
||||||
|
|
||||||
|
$exact = [
|
||||||
|
'POST api/auth/google/exchange' => ['oauth_code' => '{{oauth_code}}', 'tenant_codigo' => '{{tenant_code}}'],
|
||||||
|
'POST api/register' => ['tenant_codigo' => '{{tenant_code}}', 'nombre_apellido' => 'Usuario Demo', 'email' => '{{user_email}}', 'password' => '{{user_password}}', 'password_confirmation' => '{{user_password}}', 'dni' => '30123456', 'telefono' => '+5491112345678'],
|
||||||
|
'POST api/login' => ['email' => '{{user_email}}', 'password' => '{{user_password}}', 'tenant_codigo' => '{{tenant_code}}'],
|
||||||
|
'PUT api/me' => ['nombre_apellido' => 'Usuario Demo Actualizado', 'email' => '{{user_email}}', 'dni' => '30123456', 'telefono' => '+5491112345678'],
|
||||||
|
'POST api/password/reset-attempts' => ['tenant_codigo' => '{{tenant_code}}', 'email' => '{{user_email}}'],
|
||||||
|
'POST api/password/reset-attempts/validate' => ['email' => '{{user_email}}', 'codigo' => '{{reset_code}}'],
|
||||||
|
'POST api/password/reset' => ['email' => '{{user_email}}', 'codigo' => '{{reset_code}}', 'password' => '{{user_password}}', 'password_confirmation' => '{{user_password}}'],
|
||||||
|
'POST api/clients' => ['code' => 'cliente-demo', 'name' => 'Cliente Demo'],
|
||||||
|
'PUT api/clients/{client}' => ['code' => 'cliente-demo', 'name' => 'Cliente Demo Actualizado'],
|
||||||
|
'PATCH api/clients/{client}' => ['name' => 'Cliente Demo Actualizado'],
|
||||||
|
'POST api/integrations' => ['integration_code' => 'telepagos', 'name' => 'Telepagos', 'url' => 'https://api.example.com', 'integration_data_schema' => ['api_key' => ['required', 'string']], 'requires_client_configuration' => true],
|
||||||
|
'PUT api/integrations/{integration}' => ['name' => 'Telepagos', 'url' => 'https://api.example.com', 'requires_client_configuration' => true],
|
||||||
|
'PUT api/clients/{client}/integrations/{integration_code}' => ['integration_data' => ['api_key' => 'replace-me']],
|
||||||
|
'POST api/menues' => ['code' => 'demo', 'label' => 'Demo', 'parent_menu_code' => null, 'content_type' => 'static', 'static_content_schema' => ['title' => ['required', 'string']], 'route' => '/demo'],
|
||||||
|
'PUT api/menues/{menue}' => ['label' => 'Demo actualizado', 'route' => '/demo'],
|
||||||
|
'PATCH api/menues/{menue}' => ['label' => 'Demo actualizado'],
|
||||||
|
'POST api/{tenant_code}/menues/{menu_code}' => ['static_content' => ['title' => 'Contenido demo']],
|
||||||
|
'POST api/webhooks/telepagos/{client}' => ['id' => 'payment-id-demo'],
|
||||||
|
'POST api/{tenant_code}/mail-test/send' => ['to' => 'destinatario@example.com', 'subject' => 'Prueba ShopIt', 'message' => 'Correo de prueba enviado desde Postman.'],
|
||||||
|
'POST api/tenants/{tenant:codigo}/cart/items' => ['catalog_item_id' => '{{catalog_item_id}}', 'variant_id' => '{{variant_id}}', 'cantidad' => 1],
|
||||||
|
'PATCH api/tenants/{tenant:codigo}/cart/items/{cartItem}' => ['cantidad' => 2, 'variant_id' => '{{variant_id}}'],
|
||||||
|
'POST api/tenants/{tenant:codigo}/catalog-items/{catalogItem}/variant-options' => ['selected_values' => ['color' => 'azul'], 'cart_item_id' => '{{cart_item_id}}'],
|
||||||
|
'POST api/tenants/{tenant:codigo}/compras/start-checkout' => ['cart_id' => '{{cart_id}}'],
|
||||||
|
'PATCH api/tenants/{tenant:codigo}/compras/{compra}/customer-data' => ['dni' => '30123456', 'telefono' => '+5491112345678', 'nombre_apellido' => 'Usuario Demo', 'email' => '{{user_email}}'],
|
||||||
|
'PATCH api/tenants/{tenant:codigo}/compras/{compra}/items/{item}' => ['quantity' => 2],
|
||||||
|
'POST api/tenants/{tenant:codigo}/compras/{compra}/payment-intent' => ['method' => 'transfer', 'transfer_payer_dni' => '30123456'],
|
||||||
|
'POST api/tenants/{tenant:codigo}/tickets/pdf' => ['ticket_ids' => [1]],
|
||||||
|
'POST api/v1/adminapp/login' => ['email' => '{{admin_email}}', 'password' => '{{admin_password}}'],
|
||||||
|
'POST api/v1/adminapp/password/reset-attempts' => ['email' => '{{admin_email}}'],
|
||||||
|
'POST api/v1/adminapp/password/reset-attempts/validate' => ['email' => '{{admin_email}}', 'codigo' => '{{reset_code}}'],
|
||||||
|
'POST api/v1/adminapp/password/reset' => ['email' => '{{admin_email}}', 'codigo' => '{{reset_code}}', 'password' => '{{admin_password}}', 'password_confirmation' => '{{admin_password}}'],
|
||||||
|
'PUT api/v1/adminapp/tenant/event' => ['title' => 'Evento Demo', 'location' => 'Buenos Aires', 'dates' => [['date' => '2026-12-01', 'start_time' => '18:00', 'end_time' => '23:00']], 'social_media' => [['code' => 'instagram', 'url' => 'https://instagram.com/example', 'orden' => 0]]],
|
||||||
|
'POST api/v1/adminapp/tenant/featured-groups' => ['category_name' => 'Destacados', 'is_featured' => true],
|
||||||
|
'PUT api/v1/adminapp/tenant/featured-groups/{featuredGroup}' => ['category_name' => 'Destacados', 'is_featured' => true],
|
||||||
|
'POST api/v1/adminapp/tenant/staff' => ['nombre_apellido' => 'Operador Demo', 'dni' => '30123456', 'email' => 'operador@example.com', 'category_ids' => [1]],
|
||||||
|
'PUT api/v1/adminapp/tenant/staff/{staff}' => ['nombre_apellido' => 'Operador Demo', 'dni' => '30123456', 'email' => 'operador@example.com', 'category_ids' => [1]],
|
||||||
|
'PATCH api/v1/adminapp/tenant/staff/{staff}' => ['nombre_apellido' => 'Operador Demo', 'dni' => '30123456', 'email' => 'operador@example.com', 'category_ids' => [1]],
|
||||||
|
'PUT api/v1/adminapp/tenant/website-extras/{websiteExtraCode}' => ['enabled' => true, 'content' => ['title' => 'Contenido demo']],
|
||||||
|
'PATCH api/v1/adminapp/tenant/website-extras/{websiteExtraCode}/toggle' => ['enabled' => true],
|
||||||
|
'POST api/v1/adminapp/tenant/accommodations' => ['variants' => [['title' => 'Habitación doble', 'description' => 'Dos personas', 'stock' => 10, 'price' => 100000]]],
|
||||||
|
'POST api/v1/adminapp/tenant/entries' => ['entries' => [['title' => 'Entrada general', 'description' => 'Acceso general', 'event_date_ids' => [1], 'stock' => 100, 'price' => 25000]]],
|
||||||
|
'POST api/v1/adminapp/tenant/foods' => ['variants' => [['event_date_id' => 1, 'schedule' => '21:00', 'service' => 'Cena', 'description' => 'Menú completo', 'stock' => 100, 'price' => 15000]]],
|
||||||
|
'POST api/v1/adminapp/tenant/merchandise' => ['items' => [['title' => 'Camiseta', 'description' => 'Camiseta oficial', 'max_units_per_user' => 2, 'variants' => [['color' => 'Azul', 'size' => 'M', 'stock' => 20, 'price' => 30000]]]]],
|
||||||
|
'PUT api/v1/adminapp/tenant/desfile/entries' => ['rows' => [['type' => 'General', 'sector' => 'A', 'row' => 1, 'max_seat' => 20, 'price' => 25000]]],
|
||||||
|
'PATCH api/v1/adminapp/tenant/desfile/entries/image' => ['is_enabled' => true],
|
||||||
|
'POST api/v1/scanner/login' => ['email' => '{{scanner_email}}', 'password' => '{{scanner_password}}'],
|
||||||
|
'POST api/v1/scanner/password/reset-attempts' => ['email' => '{{scanner_email}}'],
|
||||||
|
'POST api/v1/scanner/password/reset-attempts/validate' => ['email' => '{{scanner_email}}', 'codigo' => '{{reset_code}}'],
|
||||||
|
'POST api/v1/scanner/password/reset' => ['email' => '{{scanner_email}}', 'codigo' => '{{reset_code}}', 'password' => '{{scanner_password}}', 'password_confirmation' => '{{scanner_password}}'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isset($exact[$key])) {
|
||||||
|
return jsonBody($exact[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($key === 'POST api/tenants') {
|
||||||
|
return jsonBody([
|
||||||
|
'client_id' => '{{client_id}}',
|
||||||
|
'codigo' => '{{tenant_code}}',
|
||||||
|
'nombre' => 'Tenant Demo',
|
||||||
|
'dominio' => '{{tenant_domain}}',
|
||||||
|
'site_title' => 'ShopIt Demo',
|
||||||
|
'primary_color' => '#111827',
|
||||||
|
'secondary_color' => '#2563EB',
|
||||||
|
'danger_color' => '#DC2626',
|
||||||
|
'success_color' => '#16A34A',
|
||||||
|
'header_bg_color' => '#FFFFFF',
|
||||||
|
'footer_bg_color' => '#111827',
|
||||||
|
'header_logo' => TINY_PNG,
|
||||||
|
'footer_logo' => TINY_PNG,
|
||||||
|
'search_product_layout' => 'column_with_image',
|
||||||
|
'search_group_layout' => 'paginated',
|
||||||
|
'search_items_per_page' => 12,
|
||||||
|
'display_categories' => true,
|
||||||
|
'display_seach_bar' => true,
|
||||||
|
'display_cart' => true,
|
||||||
|
'cart_editing_enabled' => true,
|
||||||
|
'website_type_code' => 'shopit',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($key, ['PUT api/tenants/{tenant}', 'PATCH api/tenants/{tenant}'], true)) {
|
||||||
|
return jsonBody(['nombre' => 'Tenant Demo Actualizado', 'site_title' => 'ShopIt Demo', 'primary_color' => '#111827', 'cart_editing_enabled' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($key === 'POST api/tenants/{tenant:codigo}/catalog-items') {
|
||||||
|
return jsonBody([
|
||||||
|
'type' => 'standard',
|
||||||
|
'category_id' => '{{category_id}}',
|
||||||
|
'slug' => 'producto-demo',
|
||||||
|
'nombre' => 'Producto Demo',
|
||||||
|
'descripcion' => 'Producto creado desde Postman',
|
||||||
|
'precio' => 10000,
|
||||||
|
'inventory_policy' => 'tracked',
|
||||||
|
'inventory_subject' => 'product',
|
||||||
|
'max_units_per_user' => 5,
|
||||||
|
'has_tickets' => false,
|
||||||
|
'real_stock' => 100,
|
||||||
|
'images' => [TINY_PNG],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($key === 'POST api/storage-test/s3/upload') {
|
||||||
|
return formDataBody(['path' => 'postman/test-file.png', 'expires_in_minutes' => '60'], ['file']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($key === 'POST api/v1/adminapp/tenant/desfile/entries/image') {
|
||||||
|
return formDataBody(['is_enabled' => '1'], ['image']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, array{key: string, value: string, disabled?: bool}> */
|
||||||
|
function queryFor(string $uri): array
|
||||||
|
{
|
||||||
|
return match ($uri) {
|
||||||
|
'api/tenants/bootstrap' => [['key' => 'dominio', 'value' => '{{tenant_domain}}'], ['key' => 'path', 'value' => '/']],
|
||||||
|
'api/v1/adminapp/bootstrap/{dominio}', 'api/v1/scanner/bootstrap/{dominio}' => [['key' => 'path', 'value' => '/']],
|
||||||
|
'api/tenants/{tenant:codigo}/catalog-items' => [['key' => 'q', 'value' => 'demo'], ['key' => 'page', 'value' => '1']],
|
||||||
|
'api/tenants/{tenant:codigo}/catalog-items/{catalogItem}' => [['key' => 'variant_id', 'value' => '{{variant_id}}', 'disabled' => true]],
|
||||||
|
'api/tenants/{tenant:codigo}/catalog/featured-groups/{featuredGroup}/items',
|
||||||
|
'api/tenants/{tenant:codigo}/categories/{category}' => [['key' => 'page', 'value' => '1']],
|
||||||
|
'api/tenants/{tenant:codigo}/compras' => [['key' => 'status', 'value' => 'pending_payment', 'disabled' => true], ['key' => 'page', 'value' => '1']],
|
||||||
|
'api/v1/adminapp/tenant/sales', 'api/v1/adminapp/tenant/sales/pdf' => [
|
||||||
|
['key' => 'q', 'value' => '', 'disabled' => true],
|
||||||
|
['key' => 'status', 'value' => 'confirmed', 'disabled' => true],
|
||||||
|
['key' => 'sale_date', 'value' => '2026-12-01', 'disabled' => true],
|
||||||
|
['key' => 'sort_by', 'value' => 'date'],
|
||||||
|
['key' => 'sort_direction', 'value' => 'desc'],
|
||||||
|
['key' => 'page', 'value' => '1'],
|
||||||
|
['key' => 'per_page', 'value' => '20'],
|
||||||
|
],
|
||||||
|
'api/v1/scanner/tickets' => [['key' => 'q', 'value' => '', 'disabled' => true], ['key' => 'page', 'value' => '1'], ['key' => 'per_page', 'value' => '20']],
|
||||||
|
'api/storage-test/s3/temporary-url' => [['key' => 'path', 'value' => '{{s3_path}}'], ['key' => 'expires_in_minutes', 'value' => '60']],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{0: string, 1: string} */
|
||||||
|
function folderFor(string $uri, string $action): array
|
||||||
|
{
|
||||||
|
preg_match('/App\\\\Domains\\\\([^\\\\]+)/', $action, $matches);
|
||||||
|
$domain = $matches[1] ?? 'Other';
|
||||||
|
|
||||||
|
if (str_starts_with($uri, 'api/v1/adminapp/')) {
|
||||||
|
return ['Admin App API', $domain];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($uri, 'api/v1/scanner/')) {
|
||||||
|
return ['Scanner API', $domain];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($uri, 'api/webhooks/')) {
|
||||||
|
return ['Webhooks', $domain];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($domain, ['StorageTest', 'MailTest'], true)) {
|
||||||
|
return ['Developer Utilities', $domain];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($domain, ['Client', 'Integration', 'Menu', 'Tenant'], true) && ! str_contains($uri, '{tenant:codigo}')) {
|
||||||
|
return ['Platform Management', $domain];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['Storefront API', $domain];
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(string $value): string
|
||||||
|
{
|
||||||
|
$value = preg_replace('/Controller$/', '', $value);
|
||||||
|
$value = preg_replace('/(?<!^)[A-Z]/', ' $0', (string) $value);
|
||||||
|
|
||||||
|
return trim(str_replace(['_', '-'], ' ', (string) $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestName(string $method, string $action, bool $multiMethod): string
|
||||||
|
{
|
||||||
|
$parts = explode('\\', explode('@', $action)[0]);
|
||||||
|
$controller = humanize(end($parts));
|
||||||
|
$handler = str_contains($action, '@') ? explode('@', $action)[1] : '__invoke';
|
||||||
|
$verbs = [
|
||||||
|
'index' => 'List', 'store' => 'Create', 'show' => 'Get', 'update' => 'Update',
|
||||||
|
'destroy' => 'Delete', 'addItem' => 'Add Item', 'updateItemQuantity' => 'Update Item Quantity',
|
||||||
|
'removeItem' => 'Remove Item', 'search' => 'Search', 'category' => 'Get Category',
|
||||||
|
'featuredGroupItems' => 'List Featured Group Items', 'variantOptions' => 'Get Variant Options',
|
||||||
|
'startCheckout' => 'Start Checkout', 'updateCustomerData' => 'Update Customer Data',
|
||||||
|
'prepareItemEditing' => 'Prepare Item Editing', 'paymentIntent' => 'Create Payment Intent',
|
||||||
|
'submitForReview' => 'Submit for Review', 'cancel' => 'Cancel', 'complete' => 'Complete',
|
||||||
|
'downloadPdf' => 'Download PDF', 'downloadModificationsPdf' => 'Download Modifications PDF',
|
||||||
|
'modifications' => 'List Modifications', 'tickets' => 'List Tickets', 'confirm' => 'Confirm',
|
||||||
|
'temporaryUrl' => 'Generate Temporary URL', 'replaceImage' => 'Replace Image',
|
||||||
|
'updateImage' => 'Update Image', 'destroyImage' => 'Delete Image', 'showExtra' => 'Get Extra',
|
||||||
|
'toggle' => 'Toggle', 'scan' => 'Scan', 'handle' => 'Receive',
|
||||||
|
];
|
||||||
|
|
||||||
|
$name = $handler === '__invoke'
|
||||||
|
? $controller
|
||||||
|
: (($verbs[$handler] ?? humanize($handler)).' '.$controller);
|
||||||
|
|
||||||
|
return $multiMethod ? $name.' ('.$method.')' : $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathFor(string $uri): string
|
||||||
|
{
|
||||||
|
$variables = [
|
||||||
|
'tenant:codigo' => 'tenant_code', 'tenant' => 'tenant_id', 'client' => 'client_id',
|
||||||
|
'integration_code' => 'integration_code', 'integration' => 'integration_id', 'menue' => 'menu_id',
|
||||||
|
'menu_code' => 'menu_code', 'tenant_code' => 'tenant_code', 'catalogItem' => 'catalog_item_id',
|
||||||
|
'featuredGroup' => 'featured_group_id', 'category' => 'category_id', 'cartItem' => 'cart_item_id',
|
||||||
|
'compra' => 'purchase_id', 'item' => 'purchase_item_id', 'dominio' => 'tenant_domain',
|
||||||
|
'sale' => 'sale_id', 'staff' => 'staff_id', 'websiteExtraCode' => 'website_extra_code',
|
||||||
|
'accommodation' => 'accommodation_id', 'entry' => 'entry_id', 'food' => 'food_id',
|
||||||
|
'merchandise' => 'merchandise_id', 'ticketUuid' => 'ticket_uuid',
|
||||||
|
];
|
||||||
|
|
||||||
|
return preg_replace_callback('/\{([^}]+)\}/', function (array $matches) use ($variables): string {
|
||||||
|
$variable = $variables[$matches[1]] ?? strtolower((string) preg_replace('/(?<!^)[A-Z]/', '_$0', $matches[1]));
|
||||||
|
|
||||||
|
return '{{'.$variable.'}}';
|
||||||
|
}, $uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
function authFor(string $uri, array $middleware): ?array
|
||||||
|
{
|
||||||
|
$protected = count(array_filter(
|
||||||
|
$middleware,
|
||||||
|
fn (string $item): bool => str_contains($item, 'Authenticate:sanctum'),
|
||||||
|
)) > 0;
|
||||||
|
|
||||||
|
if (! $protected) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$variable = str_starts_with($uri, 'api/v1/adminapp/')
|
||||||
|
? 'admin_token'
|
||||||
|
: (str_starts_with($uri, 'api/v1/scanner/') ? 'scanner_token' : 'token');
|
||||||
|
|
||||||
|
return ['type' => 'bearer', 'bearer' => [['key' => 'token', 'value' => '{{'.$variable.'}}', 'type' => 'string']]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
function tokenCaptureEvent(string $uri): array
|
||||||
|
{
|
||||||
|
$variable = $uri === 'api/v1/adminapp/login' ? 'admin_token' : ($uri === 'api/v1/scanner/login' ? 'scanner_token' : 'token');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'listen' => 'test',
|
||||||
|
'script' => [
|
||||||
|
'type' => 'text/javascript',
|
||||||
|
'exec' => [
|
||||||
|
'if (pm.response.code >= 200 && pm.response.code < 300) {',
|
||||||
|
' const payload = pm.response.json();',
|
||||||
|
" if (payload.token) pm.collectionVariables.set('{$variable}', payload.token);",
|
||||||
|
'}',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tree = [];
|
||||||
|
$registeredOperations = 0;
|
||||||
|
|
||||||
|
foreach ($routes as $route) {
|
||||||
|
$methods = array_values(array_filter(explode('|', $route['method']), fn (string $method): bool => $method !== 'HEAD'));
|
||||||
|
$multiMethod = count($methods) > 1;
|
||||||
|
|
||||||
|
foreach ($methods as $method) {
|
||||||
|
[$section, $folder] = folderFor($route['uri'], $route['action']);
|
||||||
|
$path = pathFor($route['uri']);
|
||||||
|
$query = queryFor($route['uri']);
|
||||||
|
$enabledQuery = array_values(array_filter($query, fn (array $item): bool => ! ($item['disabled'] ?? false)));
|
||||||
|
$queryString = $enabledQuery === [] ? '' : '?'.implode('&', array_map(
|
||||||
|
fn (array $item): string => rawurlencode($item['key']).'='.$item['value'],
|
||||||
|
$enabledQuery,
|
||||||
|
));
|
||||||
|
$body = bodyFor($method, $route['uri']);
|
||||||
|
$auth = authFor($route['uri'], $route['middleware']);
|
||||||
|
$headers = [['key' => 'Accept', 'value' => 'application/json', 'type' => 'text']];
|
||||||
|
|
||||||
|
if (($body['mode'] ?? null) === 'raw') {
|
||||||
|
$headers[] = ['key' => 'Content-Type', 'value' => 'application/json', 'type' => 'text'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = [
|
||||||
|
'method' => $method,
|
||||||
|
'header' => $headers,
|
||||||
|
'description' => sprintf(
|
||||||
|
"Ruta Laravel: `%s /%s`\n\nControlador: `%s`%s",
|
||||||
|
$method,
|
||||||
|
$route['uri'],
|
||||||
|
$route['action'],
|
||||||
|
$auth === null ? '' : "\n\nRequiere autenticación Sanctum.",
|
||||||
|
),
|
||||||
|
'url' => [
|
||||||
|
'raw' => '{{base_url}}/'.$path.$queryString,
|
||||||
|
'host' => ['{{base_url}}'],
|
||||||
|
'path' => explode('/', $path),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($query !== []) {
|
||||||
|
$request['url']['query'] = $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($body !== null) {
|
||||||
|
$request['body'] = $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($auth !== null) {
|
||||||
|
$request['auth'] = $auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = [
|
||||||
|
'name' => requestName($method, $route['action'], $multiMethod),
|
||||||
|
'request' => $request,
|
||||||
|
'response' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (in_array($route['uri'], ['api/login', 'api/v1/adminapp/login', 'api/v1/scanner/login'], true)) {
|
||||||
|
$item['event'] = [tokenCaptureEvent($route['uri'])];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tree[$section][$folder][] = $item;
|
||||||
|
$registeredOperations++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$sectionDescriptions = [
|
||||||
|
'Storefront API' => 'API pública y autenticada consumida por el storefront: autenticación, catálogo, carrito, compras y tickets.',
|
||||||
|
'Admin App API' => 'Operaciones de administración del tenant. Ejecutá Login primero para completar `admin_token`.',
|
||||||
|
'Scanner API' => 'Operaciones de la aplicación de escaneo. Ejecutá Login primero para completar `scanner_token`.',
|
||||||
|
'Platform Management' => 'CRUD y configuración de tenants, clientes, menús e integraciones.',
|
||||||
|
'Webhooks' => 'Entradas públicas para notificaciones de proveedores externos.',
|
||||||
|
'Developer Utilities' => 'Endpoints de diagnóstico de correo y almacenamiento. No deberían exponerse en producción.',
|
||||||
|
];
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach ($tree as $section => $folders) {
|
||||||
|
$children = [];
|
||||||
|
foreach ($folders as $folder => $requests) {
|
||||||
|
$children[] = ['name' => humanize($folder), 'item' => $requests];
|
||||||
|
}
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'name' => $section,
|
||||||
|
'description' => $sectionDescriptions[$section] ?? '',
|
||||||
|
'item' => $children,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$variables = [
|
||||||
|
'base_url' => 'http://localhost:8000',
|
||||||
|
'token' => '', 'admin_token' => '', 'scanner_token' => '',
|
||||||
|
'user_email' => 'usuario@example.com', 'user_password' => 'Password!123',
|
||||||
|
'admin_email' => 'admin@example.com', 'admin_password' => 'Password!123',
|
||||||
|
'scanner_email' => 'scanner@example.com', 'scanner_password' => 'Password!123',
|
||||||
|
'tenant_code' => 'demo', 'tenant_id' => '1', 'tenant_domain' => 'demo.test',
|
||||||
|
'client_id' => '1', 'integration_id' => '1', 'integration_code' => 'telepagos',
|
||||||
|
'menu_id' => '1', 'menu_code' => 'demo', 'catalog_item_id' => '1', 'variant_id' => '1',
|
||||||
|
'category_id' => '1', 'featured_group_id' => '1', 'cart_id' => '1', 'cart_item_id' => '1',
|
||||||
|
'purchase_id' => '1', 'purchase_item_id' => '1', 'sale_id' => '1', 'staff_id' => '1',
|
||||||
|
'website_extra_code' => 'hero', 'accommodation_id' => '1', 'entry_id' => '1', 'food_id' => '1',
|
||||||
|
'merchandise_id' => '1', 'ticket_uuid' => '00000000-0000-0000-0000-000000000000',
|
||||||
|
'oauth_code' => '00000000-0000-0000-0000-000000000000', 'reset_code' => '1234',
|
||||||
|
's3_path' => 'postman/test-file.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
$collection = [
|
||||||
|
'info' => [
|
||||||
|
'_postman_id' => '76fd6fd2-53b9-4d92-8e02-1ddcc6207fa2',
|
||||||
|
'name' => 'ShopIt API — Complete',
|
||||||
|
'description' => "Colección canónica generada desde las rutas reales de Laravel. Incluye {$registeredOperations} operaciones HTTP, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.",
|
||||||
|
'schema' => 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
|
||||||
|
],
|
||||||
|
'item' => $items,
|
||||||
|
'variable' => array_map(
|
||||||
|
fn (string $key, string $value): array => ['key' => $key, 'value' => $value, 'type' => 'string'],
|
||||||
|
array_keys($variables),
|
||||||
|
array_values($variables),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
$output = $root.DIRECTORY_SEPARATOR.'ShopIt_API_Postman_Collection.json';
|
||||||
|
file_put_contents($output, json_encode($collection, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE).PHP_EOL);
|
||||||
|
|
||||||
|
fwrite(STDOUT, "Generated {$registeredOperations} operations in {$output}\n");
|
||||||
@@ -75,6 +75,21 @@ class CartControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_filters_item_images_when_the_tenant_disables_them(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
$tenant->update(['display_cart_item_images' => false]);
|
||||||
|
$item = $this->createDirectItem($tenant, 10, '49.90');
|
||||||
|
$item->attachments()->attach($this->createAttachment('cart-item'), ['orden' => 0]);
|
||||||
|
|
||||||
|
$this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'catalog_item_id' => $item->id,
|
||||||
|
'cantidad' => 1,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.items.0.imagen', null);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_adds_a_specific_variant_and_merges_repeated_additions(): void
|
public function test_it_adds_a_specific_variant_and_merges_repeated_additions(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->createTenant('acme');
|
$tenant = $this->createTenant('acme');
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Migrations;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AddCartEditingEnabledToTenantsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_disables_cart_editing_only_for_desfile(): void
|
||||||
|
{
|
||||||
|
$migration = require database_path(
|
||||||
|
'migrations/2026_08_18_060000_add_cart_editing_enabled_to_tenants_table.php'
|
||||||
|
);
|
||||||
|
|
||||||
|
$migration->down();
|
||||||
|
$migration->up();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tenants', [
|
||||||
|
'codigo' => 'desfile_pura_tendencia',
|
||||||
|
'cart_editing_enabled' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('tenants')->insert([
|
||||||
|
'codigo' => 'cart-editing-default',
|
||||||
|
'nombre' => 'Cart editing default',
|
||||||
|
'dominio' => 'cart-editing-default.test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tenants', [
|
||||||
|
'codigo' => 'cart-editing-default',
|
||||||
|
'cart_editing_enabled' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Migrations;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AddDisplayCartItemImagesToTenantsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_hides_cart_item_images_only_for_desfile(): void
|
||||||
|
{
|
||||||
|
$migration = require database_path(
|
||||||
|
'migrations/2026_08_18_061000_add_display_cart_item_images_to_tenants_table.php'
|
||||||
|
);
|
||||||
|
|
||||||
|
$migration->down();
|
||||||
|
$migration->up();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tenants', [
|
||||||
|
'codigo' => 'desfile_pura_tendencia',
|
||||||
|
'display_cart_item_images' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('tenants')->insert([
|
||||||
|
'codigo' => 'cart-images-default',
|
||||||
|
'nombre' => 'Cart images default',
|
||||||
|
'dominio' => 'cart-images-default.test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tenants', [
|
||||||
|
'codigo' => 'cart-images-default',
|
||||||
|
'display_cart_item_images' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -847,6 +847,30 @@ class StorePurchaseTest extends TestCase
|
|||||||
->assertJsonPath('data.total', '100.00');
|
->assertJsonPath('data.total', '100.00');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_purchase_detail_filters_item_images_when_the_tenant_disables_them(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
|
$tenant->update(['display_cart_item_images' => false]);
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'buyer@example.com',
|
||||||
|
]);
|
||||||
|
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||||
|
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||||
|
$image = Attachment::create([
|
||||||
|
'key' => (string) Str::uuid(),
|
||||||
|
'path' => 'catalog/purchase-item.png',
|
||||||
|
'filename' => 'purchase-item.png',
|
||||||
|
'type' => AttachmentType::Image,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
]);
|
||||||
|
$purchase->items()->firstOrFail()->update(['image_attachment_id' => $image->id]);
|
||||||
|
|
||||||
|
$this->actingAs($user, 'sanctum')
|
||||||
|
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.items.0.item_details.imagen', null);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_purchase_detail_uses_purchase_items_for_pending_payment_purchase(): void
|
public function test_purchase_detail_uses_purchase_items_for_pending_payment_purchase(): void
|
||||||
{
|
{
|
||||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ class DesfilePuraTendenciaSeederTest extends TestCase
|
|||||||
'footer_bg_color' => '#D4441C',
|
'footer_bg_color' => '#D4441C',
|
||||||
'display_categories' => false,
|
'display_categories' => false,
|
||||||
'display_seach_bar' => false,
|
'display_seach_bar' => false,
|
||||||
|
'cart_editing_enabled' => false,
|
||||||
|
'display_cart_item_images' => false,
|
||||||
'site_title' => 'Desfile Pura Tendencia',
|
'site_title' => 'Desfile Pura Tendencia',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
'display_categories' => false,
|
'display_categories' => false,
|
||||||
'display_seach_bar' => false,
|
'display_seach_bar' => false,
|
||||||
'display_cart' => false,
|
'display_cart' => false,
|
||||||
|
'cart_editing_enabled' => false,
|
||||||
|
'display_cart_item_images' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->getJson('/api/tenants/bootstrap?dominio=acme.com&path=%2F');
|
$response = $this->getJson('/api/tenants/bootstrap?dominio=acme.com&path=%2F');
|
||||||
@@ -89,6 +91,8 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
->assertJsonPath('data.display_categories', false)
|
->assertJsonPath('data.display_categories', false)
|
||||||
->assertJsonPath('data.display_seach_bar', false)
|
->assertJsonPath('data.display_seach_bar', false)
|
||||||
->assertJsonPath('data.display_cart', false)
|
->assertJsonPath('data.display_cart', false)
|
||||||
|
->assertJsonPath('data.cart_editing_enabled', false)
|
||||||
|
->assertJsonPath('data.display_cart_item_images', false)
|
||||||
->assertJsonPath('data.header_bg_color', '#ffffff')->assertJsonPath('data.footer_bg_color', '#ffffff');
|
->assertJsonPath('data.header_bg_color', '#ffffff')->assertJsonPath('data.footer_bg_color', '#ffffff');
|
||||||
|
|
||||||
$headerUrl = $response->json('data.header_logo');
|
$headerUrl = $response->json('data.header_logo');
|
||||||
@@ -138,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
|
||||||
@@ -167,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')
|
||||||
@@ -570,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')
|
||||||
@@ -609,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',
|
||||||
@@ -622,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',
|
||||||
@@ -745,19 +758,25 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
|
|
||||||
$response = $this->putJson("/api/tenants/{$tenant->codigo}", [
|
$response = $this->putJson("/api/tenants/{$tenant->codigo}", [
|
||||||
'primary_color' => '#000000',
|
'primary_color' => '#000000',
|
||||||
|
'cart_editing_enabled' => false,
|
||||||
|
'display_cart_item_images' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.codigo', 'acme')
|
->assertJsonPath('data.codigo', 'acme')
|
||||||
->assertJsonPath('data.nombre', 'Acme')
|
->assertJsonPath('data.nombre', 'Acme')
|
||||||
->assertJsonPath('data.primary_color', '#000000');
|
->assertJsonPath('data.primary_color', '#000000')
|
||||||
|
->assertJsonPath('data.cart_editing_enabled', false)
|
||||||
|
->assertJsonPath('data.display_cart_item_images', false);
|
||||||
|
|
||||||
$this->assertDatabaseHas('tenants', [
|
$this->assertDatabaseHas('tenants', [
|
||||||
'id' => $tenant->id,
|
'id' => $tenant->id,
|
||||||
'codigo' => 'acme',
|
'codigo' => 'acme',
|
||||||
'nombre' => 'Acme',
|
'nombre' => 'Acme',
|
||||||
'primary_color' => '#000000',
|
'primary_color' => '#000000',
|
||||||
|
'cart_editing_enabled' => false,
|
||||||
|
'display_cart_item_images' => false,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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