refactor(backend): reorganize domains into Core, Commerce, Ticketing and Shared

This commit is contained in:
2026-09-18 10:14:02 -03:00
parent 4659c1049d
commit 1241e1f7e8
425 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Domains\Tenant\Controllers\AdminApp;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Requests\AdminApp\UpdateWebsiteExtraRequest;
use App\Domains\Tenant\Resources\AdminApp\WebsiteExtraResource;
use App\Domains\Tenant\Resources\AdminApp\WebsiteExtrasResource;
use App\Domains\Tenant\Services\TenantInformationService;
use App\Domains\Tenant\Services\WebsiteExtraService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class WebsiteExtraController extends Controller
{
public function __construct(
protected WebsiteExtraService $websiteExtraService,
protected TenantInformationService $tenantInformationService,
) {}
public function show(Request $request): WebsiteExtrasResource
{
return WebsiteExtrasResource::make(
$this->loadTenant($request->user())
);
}
public function showExtra(Request $request, string $websiteExtraCode): WebsiteExtraResource
{
$tenant = $this->loadTenant($request->user());
$definition = $this->websiteExtraService->definitionForTenant($tenant, $websiteExtraCode);
$websiteExtra = $tenant->websiteExtras
->firstWhere('website_type_extra_id', $definition->id);
if (! $websiteExtra) {
abort(404);
}
return WebsiteExtraResource::make($websiteExtra);
}
public function update(
UpdateWebsiteExtraRequest $request,
string $websiteExtraCode
): WebsiteExtrasResource {
$tenant = $request->user()->tenant()->firstOrFail();
$this->websiteExtraService->updateForTenant(
$tenant,
$websiteExtraCode,
$request->validated('config')
);
return WebsiteExtrasResource::make(
$this->loadTenant($request->user())
);
}
public function toggle(Request $request, string $websiteExtraCode): WebsiteExtrasResource
{
$tenant = $request->user()->tenant()->firstOrFail();
$this->websiteExtraService->toggleForTenant($tenant, $websiteExtraCode);
return WebsiteExtrasResource::make(
$this->loadTenant($request->user())
)->additional([
'code' => 'tenant.website_extra_toggled',
'message' => __('api.tenant.website_extra_toggled'),
]);
}
private function loadTenant(User $user): Tenant
{
$tenant = $user->tenant()->firstOrFail();
return $this->tenantInformationService->load($tenant, [
'storefrontWebsiteType.extras',
]);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Domains\Tenant\Controllers;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Requests\StoreTenantRequest;
use App\Domains\Tenant\Requests\UpdateTenantRequest;
use App\Domains\Tenant\Resources\TenantResource;
use App\Domains\Tenant\Services\TenantInformationService;
use App\Domains\Tenant\Services\TenantService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class TenantController extends Controller
{
public function __construct(
protected TenantService $tenantService,
protected TenantInformationService $tenantInformationService,
) {}
public function index(): JsonResponse
{
$tenants = Tenant::query()
->latest()
->paginateFromRequest();
$this->tenantInformationService->loadMany($tenants->getCollection());
return TenantResource::collection($tenants)->response();
}
public function store(StoreTenantRequest $request): JsonResponse
{
$tenant = $this->tenantService->create($request->validated());
return TenantResource::make(
$this->tenantInformationService->load($tenant)
)->response()->setStatusCode(201);
}
public function show(Tenant $tenant): TenantResource
{
return TenantResource::make(
$this->tenantInformationService->load($tenant)
);
}
public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource
{
$tenant = $this->tenantService->update($tenant, $request->validated());
return TenantResource::make(
$this->tenantInformationService->load($tenant)
);
}
public function destroy(Tenant $tenant): Response
{
$tenant->delete();
return response()->noContent();
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Tenant\Enums;
enum CartEditingPolicy: string
{
case Disabled = 'disabled';
case QuantityAndRemove = 'quantity_and_remove';
case Full = 'full';
public function allowsQuantityChanges(): bool
{
return $this !== self::Disabled;
}
public function allowsRemoval(): bool
{
return $this !== self::Disabled;
}
public function allowsVariantChanges(): bool
{
return $this === self::Full;
}
public function allowsModification(): bool
{
return $this->allowsRemoval()
|| $this->allowsQuantityChanges()
|| $this->allowsVariantChanges();
}
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Domains\Tenant\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Integration\Models\AdminWebsiteTypeIntegration;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'codigo',
'nombre',
'dominio',
'scanner_domain',
'site_title',
'primary_color',
'secondary_color',
'danger_color',
'success_color',
'warning_color',
'body_color',
'darker_body_color',
'surface_color',
'background_color',
'border_color',
'login_header_footer_color',
'site_logo',
'footer_logo',
'favicon_id',
])]
class AdminWebsiteType extends Model
{
use HasFactory;
protected $table = 'admin_website_types';
/** @return HasMany<AdminWebsiteTypeIntegration, $this> */
public function integrations(): HasMany
{
return $this->hasMany(AdminWebsiteTypeIntegration::class, 'admin_website_type_code', 'codigo');
}
/**
* @return BelongsTo<Attachment, $this>
*/
public function siteLogo(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'site_logo');
}
/**
* @return BelongsTo<Attachment, $this>
*/
public function footerLogo(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'footer_logo');
}
/**
* @return BelongsTo<Attachment, $this>
*/
public function favicon(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'favicon_id');
}
/**
* @return HasMany<Tenant, $this>
*/
public function tenants(): HasMany
{
return $this->hasMany(Tenant::class, 'admin_website_type_code', 'codigo');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Domains\Tenant\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class SocialMedia extends Model
{
protected $table = 'social_media';
protected $fillable = [
'code',
'icon',
'name',
];
/**
* @return BelongsToMany<Tenant, $this>
*/
public function tenants(): BelongsToMany
{
return $this->belongsToMany(
Tenant::class,
'tenant_social_media',
'social_media_code',
'tenant_code',
'code',
'codigo'
)
->withPivot(['url', 'orden'])
->withTimestamps()
->orderByPivot('orden');
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domains\Tenant\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['codigo', 'nombre', 'header_type'])]
class StorefrontWebsiteType extends Model
{
use HasFactory;
protected $table = 'storefront_website_types';
/** @return HasMany<StorefrontWebsiteTypeExtra, $this> */
public function extras(): HasMany
{
return $this->hasMany(StorefrontWebsiteTypeExtra::class, 'storefront_website_type_code', 'codigo');
}
/** @return HasMany<Tenant, $this> */
public function tenants(): HasMany
{
return $this->hasMany(Tenant::class, 'storefront_website_type_code', 'codigo');
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Domains\Tenant\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'storefront_website_type_code',
'codigo',
'nombre',
'descripcion',
'is_required',
'config_schema',
])]
class StorefrontWebsiteTypeExtra extends Model
{
use HasFactory;
protected $table = 'storefront_website_type_extras';
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_required' => 'boolean',
'config_schema' => 'array',
];
}
/**
* @return BelongsTo<StorefrontWebsiteType, $this>
*/
public function storefrontWebsiteType(): BelongsTo
{
return $this->belongsTo(StorefrontWebsiteType::class, 'storefront_website_type_code', 'codigo');
}
/**
* @return HasMany<WebsiteExtra, $this>
*/
public function websiteExtras(): HasMany
{
return $this->hasMany(WebsiteExtra::class, 'website_type_extra_id');
}
}

View File

@@ -0,0 +1,302 @@
<?php
namespace App\Domains\Tenant\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Client\Models\Client;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Models\EventDateChange;
use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Models\TenantMenu;
use App\Domains\Tenant\Enums\CartEditingPolicy;
use App\Domains\Ticket\Models\ScanAttempt;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Schema;
#[Fillable([
'client_id',
'codigo',
'nombre',
'dominio',
'base_path',
'site_title',
'address',
'phone',
'primary_color',
'secondary_color',
'danger_color',
'success_color',
'header_bg_color',
'footer_bg_color',
'header_logo_id',
'footer_logo_id',
'favicon_id',
'header_bg_image_id',
'footer_bg_image_id',
'admin_website_type_code',
'storefront_website_type_code',
'search_product_layout',
'search_group_layout',
'search_items_per_page',
'display_categories',
'display_seach_bar',
'display_cart',
'cart_editing_policy',
'checkout_editing_policy',
'display_cart_item_images',
'scanner_category_validation_enabled',
'allow_ticket_refund',
'allow_ticket_total_refund',
'allow_ticket_partial_refund',
'ticket_partial_refund_percentage',
'event_title',
'event_location',
'event_date_text',
])]
class Tenant extends Model
{
use HasFactory;
protected $attributes = [
'base_path' => '/',
'search_product_layout' => ProductLayout::ColumnWithImage->value,
'search_group_layout' => GroupLayout::Paginated->value,
'search_items_per_page' => 12,
'display_categories' => true,
'display_seach_bar' => true,
'display_cart' => true,
'cart_editing_policy' => CartEditingPolicy::Full->value,
'checkout_editing_policy' => CartEditingPolicy::Disabled->value,
'display_cart_item_images' => true,
'scanner_category_validation_enabled' => true,
'allow_ticket_refund' => false,
'allow_ticket_total_refund' => false,
'allow_ticket_partial_refund' => false,
'ticket_partial_refund_percentage' => 0,
];
public function getRouteKeyName(): string
{
return 'codigo';
}
protected static function booted(): void
{
static::creating(function (Tenant $tenant): void {
if ($tenant->client_id !== null || ! Schema::hasTable('clients')) {
return;
}
$client = Client::query()->firstOrCreate(
['code' => $tenant->codigo],
['name' => $tenant->nombre],
);
$tenant->client()->associate($client);
});
}
/** @return BelongsTo<Client, $this> */
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function requiresScannerCategoryValidation(): bool
{
return $this->scanner_category_validation_enabled;
}
public function allow_refund(): bool
{
return (bool) $this->allow_ticket_refund
&& ((bool) $this->allow_ticket_total_refund || $this->allow_partial_refund());
}
public function allow_partial_refund(): bool
{
return (bool) $this->allow_ticket_refund
&& (bool) $this->allow_ticket_partial_refund
&& $this->ticket_partial_refund_percentage !== null
&& (float) $this->ticket_partial_refund_percentage > 0;
}
public function allowRefund(): bool
{
return $this->allow_refund();
}
public function allowPartialRefund(): bool
{
return $this->allow_partial_refund();
}
public function getAllowRefundAttribute(): bool
{
return $this->allow_refund();
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'search_product_layout' => ProductLayout::class,
'search_group_layout' => GroupLayout::class,
'search_items_per_page' => 'integer',
'display_categories' => 'boolean',
'display_seach_bar' => 'boolean',
'display_cart' => 'boolean',
'cart_editing_policy' => CartEditingPolicy::class,
'checkout_editing_policy' => CartEditingPolicy::class,
'display_cart_item_images' => 'boolean',
'scanner_category_validation_enabled' => 'boolean',
'allow_ticket_refund' => 'boolean',
'allow_ticket_total_refund' => 'boolean',
'allow_ticket_partial_refund' => 'boolean',
'ticket_partial_refund_percentage' => 'decimal:2',
];
}
/**
* @return BelongsTo<Attachment, $this>
*/
public function headerLogo(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'header_logo_id');
}
/**
* @return BelongsTo<Attachment, $this>
*/
public function footerLogo(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'footer_logo_id');
}
/**
* @return BelongsTo<Attachment, $this>
*/
public function favicon(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'favicon_id');
}
/**
* @return BelongsTo<Attachment, $this>
*/
public function headerBackgroundImage(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'header_bg_image_id');
}
/**
* @return BelongsTo<Attachment, $this>
*/
public function footerBackgroundImage(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'footer_bg_image_id');
}
/**
* @return BelongsTo<AdminWebsiteType, $this>
*/
public function adminWebsiteType(): BelongsTo
{
return $this->belongsTo(AdminWebsiteType::class, 'admin_website_type_code', 'codigo');
}
/** @return BelongsTo<StorefrontWebsiteType, $this> */
public function storefrontWebsiteType(): BelongsTo
{
return $this->belongsTo(StorefrontWebsiteType::class, 'storefront_website_type_code', 'codigo');
}
public function catalogItems(): HasMany
{
return $this->hasMany(CatalogItem::class, 'tenant_code', 'codigo');
}
/** @return HasMany<EventDate, $this> */
public function eventDates(): HasMany
{
return $this->hasMany(EventDate::class, 'tenant_code', 'codigo')
->orderBy('date')
->orderBy('time_start');
}
/** @return HasMany<EventDateChange, $this> */
public function eventDateChanges(): HasMany
{
return $this->hasMany(EventDateChange::class, 'tenant_code', 'codigo')
->orderBy('created_at')
->orderBy('id');
}
/**
* @return HasMany<Category, $this>
*/
public function categories(): HasMany
{
return $this->hasMany(Category::class, 'tenant_code', 'codigo');
}
/** @return HasMany<ScanAttempt, $this> */
public function scanAttempts(): HasMany
{
return $this->hasMany(ScanAttempt::class, 'tenant_code', 'codigo');
}
/**
* @return BelongsToMany<SocialMedia, $this>
*/
public function socialMedia(): BelongsToMany
{
return $this->belongsToMany(
SocialMedia::class,
'tenant_social_media',
'tenant_code',
'social_media_code',
'codigo',
'code'
)
->withPivot(['url', 'orden'])
->withTimestamps()
->orderByPivot('orden');
}
/**
* @return HasMany<WebsiteExtra, $this>
*/
public function websiteExtras(): HasMany
{
return $this->hasMany(WebsiteExtra::class, 'website_code', 'codigo');
}
public function menues(): BelongsToMany
{
return $this->belongsToMany(
Menu::class,
'tenants_menues',
'tenant_code',
'menu_code',
'codigo',
'code'
)
->using(TenantMenu::class)
->withPivot('static_content')
->withTimestamps();
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Domains\Tenant\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'website_code',
'website_type_extra_id',
'config',
'is_enabled',
])]
class WebsiteExtra extends Model
{
use HasFactory;
protected $table = 'websites_extras';
protected $attributes = [
'is_enabled' => true,
];
private mixed $resolvedConfig = null;
private bool $hasResolvedConfig = false;
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'website_type_extra_id' => 'integer',
'config' => 'array',
'is_enabled' => 'boolean',
];
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function website(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'website_code', 'codigo');
}
/**
* @return BelongsTo<StorefrontWebsiteTypeExtra, $this>
*/
public function websiteTypeExtra(): BelongsTo
{
return $this->belongsTo(StorefrontWebsiteTypeExtra::class, 'website_type_extra_id');
}
public function setResolvedConfig(mixed $config): self
{
$this->resolvedConfig = $config;
$this->hasResolvedConfig = true;
return $this;
}
public function resolvedConfig(): mixed
{
return $this->hasResolvedConfig ? $this->resolvedConfig : $this->config;
}
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Tenant\Requests\AdminApp;
use App\Domains\Tenant\Services\WebsiteExtraService;
use Illuminate\Foundation\Http\FormRequest;
class UpdateWebsiteExtraRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$tenant = $this->user()->tenant()->firstOrFail();
return app(WebsiteExtraService::class)->requestRulesForExtra(
$tenant,
(string) $this->route('websiteExtraCode')
);
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace App\Domains\Tenant\Requests;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Tenant\Enums\CartEditingPolicy;
use App\Domains\Tenant\Services\WebsiteExtraService;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreTenantRequest extends FormRequest
{
protected bool $hasInvalidDomain = false;
protected bool $hasInvalidBasePath = false;
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$rawDomain = $this->input('dominio');
$hasExplicitBasePath = $this->has('base_path');
$rawBasePath = $hasExplicitBasePath
? $this->input('base_path')
: TenantDomainNormalizer::pathFromDomain($rawDomain);
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
$normalizedBasePath = TenantDomainNormalizer::normalizePath($rawBasePath);
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
&& $normalizedDomain === null;
$this->hasInvalidBasePath = ($hasExplicitBasePath && ! is_string($rawBasePath))
|| $normalizedBasePath === null;
$this->merge([
'dominio' => $normalizedDomain,
'base_path' => $normalizedBasePath,
]);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$logoRule = ['required', new ImageOrBase64Rule];
return array_merge([
'client_id' => ['sometimes', 'integer', Rule::exists('clients', 'id')],
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
'nombre' => ['required', 'string', 'max:255'],
'dominio' => [
'bail',
function (string $attribute, mixed $value, Closure $fail): void {
if ($this->hasInvalidDomain) {
$fail("The {$attribute} field must contain a valid domain or URL.");
}
},
'required',
'string',
'max:255',
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'],
'address' => ['sometimes', 'nullable', 'string', 'max:255'],
'phone' => ['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})$/'],
'secondary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'danger_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'success_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'footer_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_logo' => $logoRule,
'footer_logo' => $logoRule,
'favicon' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'header_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'footer_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'social_media' => ['sometimes', 'array'],
'social_media.*.code' => [
'required',
'string',
'distinct',
Rule::exists('social_media', 'code'),
],
'social_media.*.url' => ['required', 'url', 'max:2048'],
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
'display_categories' => ['sometimes', 'boolean'],
'display_seach_bar' => ['sometimes', 'boolean'],
'display_cart' => ['sometimes', 'boolean'],
'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)],
'checkout_editing_policy' => [
'sometimes',
Rule::in([CartEditingPolicy::Disabled->value]),
],
'display_cart_item_images' => ['sometimes', 'boolean'],
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
'allow_ticket_refund' => ['sometimes', 'boolean'],
'allow_ticket_total_refund' => ['sometimes', 'boolean'],
'allow_ticket_partial_refund' => ['sometimes', 'boolean'],
'ticket_partial_refund_percentage' => [
'sometimes',
'numeric',
'decimal:0,2',
'min:0',
'max:99.99',
],
'storefront_website_type_code' => [
'required_with:extras',
'sometimes',
'string',
Rule::exists('storefront_website_types', 'codigo'),
],
'admin_website_type_code' => ['sometimes', 'nullable', 'string', Rule::exists('admin_website_types', 'codigo')],
], app(WebsiteExtraService::class)->requestRules($this->input('storefront_website_type_code')));
}
}

View File

@@ -0,0 +1,165 @@
<?php
namespace App\Domains\Tenant\Requests;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Tenant\Enums\CartEditingPolicy;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateTenantRequest extends FormRequest
{
protected bool $hasInvalidDomain = false;
protected bool $hasInvalidBasePath = false;
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
if ($this->has('dominio')) {
$rawDomain = $this->input('dominio');
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
$embeddedBasePath = TenantDomainNormalizer::pathFromDomain($rawDomain);
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
&& ($normalizedDomain === null || $embeddedBasePath === null);
$this->merge([
'dominio' => $normalizedDomain,
]);
if (! $this->has('base_path') && $embeddedBasePath !== null && $embeddedBasePath !== '/') {
$this->merge(['base_path' => $embeddedBasePath]);
}
}
if ($this->has('base_path')) {
$rawBasePath = $this->input('base_path');
$normalizedBasePath = TenantDomainNormalizer::normalizePath($rawBasePath);
$this->hasInvalidBasePath = ! is_string($rawBasePath)
|| $normalizedBasePath === null;
$this->merge(['base_path' => $normalizedBasePath]);
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var Tenant|null $tenant */
$tenant = $this->route('tenant');
$domain = $this->input('dominio', $tenant?->dominio);
$basePath = $this->input('base_path', $tenant?->base_path ?? '/');
$logoRule = ['nullable', new ImageOrBase64Rule];
return [
'client_id' => ['sometimes', 'integer', Rule::exists('clients', 'id')],
'codigo' => [
'nullable',
'string',
'max:255',
Rule::unique('tenants', 'codigo')->ignore($tenant?->id),
],
'nombre' => ['nullable', 'string', 'max:255'],
'dominio' => [
'bail',
function (string $attribute, mixed $value, Closure $fail): void {
if ($this->hasInvalidDomain) {
$fail("The {$attribute} field must contain a valid domain or URL.");
}
},
'nullable',
'string',
'max:255',
Rule::unique('tenants', 'dominio')
->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'],
'address' => ['sometimes', 'nullable', 'string', 'max:255'],
'phone' => ['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})$/'],
'secondary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'danger_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'success_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_logo' => $logoRule,
'footer_logo' => $logoRule,
'favicon' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'header_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'footer_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'social_media' => ['sometimes', 'array'],
'social_media.*.code' => [
'required',
'string',
'distinct',
Rule::exists('social_media', 'code'),
],
'social_media.*.url' => ['required', 'url', 'max:2048'],
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
'display_categories' => ['sometimes', 'boolean'],
'display_seach_bar' => ['sometimes', 'boolean'],
'display_cart' => ['sometimes', 'boolean'],
'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)],
'checkout_editing_policy' => [
'sometimes',
Rule::in([CartEditingPolicy::Disabled->value]),
],
'display_cart_item_images' => ['sometimes', 'boolean'],
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
'allow_ticket_refund' => ['sometimes', 'boolean'],
'allow_ticket_total_refund' => ['sometimes', 'boolean'],
'allow_ticket_partial_refund' => ['sometimes', 'boolean'],
'ticket_partial_refund_percentage' => [
'sometimes',
'numeric',
'decimal:0,2',
'min:0',
'max:99.99',
],
'admin_website_type_code' => ['sometimes', 'nullable', 'string', Rule::exists('admin_website_types', 'codigo')],
'storefront_website_type_code' => [
'sometimes',
'nullable',
'string',
Rule::exists('storefront_website_types', 'codigo'),
function (string $attribute, mixed $value, Closure $fail) use ($tenant): void {
if ($tenant && $value !== $tenant->storefront_website_type_code && $tenant->websiteExtras()->exists()) {
$fail('The storefront website type cannot be changed while the tenant has extras.');
}
},
],
];
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Domains\Tenant\Resources\AdminApp;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Models\AttachmentCrop;
use App\Domains\Tenant\Models\WebsiteExtra;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin WebsiteExtra
*/
class WebsiteExtraResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'codigo' => $this->websiteTypeExtra->codigo,
'nombre' => $this->websiteTypeExtra->nombre,
'descripcion' => $this->websiteTypeExtra->descripcion,
'is_required' => $this->websiteTypeExtra->is_required,
'is_enabled' => $this->is_enabled,
'request_rules' => $this->websiteTypeExtra->config_schema['request_rules'] ?? [],
'config' => $this->formatConfig(
$this->resolvedConfig(),
fn (Attachment $attachment): string => $attachment->key
),
'resolved_config' => $this->formatConfig(
$this->resolvedAdminConfig(),
fn (Attachment $attachment): string => $attachment->getTemporaryUrl(1440)
),
];
}
private function resolvedAdminConfig(): mixed
{
$config = $this->resolvedConfig();
if (
$this->websiteTypeExtra->codigo !== 'heroConfig'
|| ! is_array($config)
|| ! ($config['background_image_id'] ?? null) instanceof Attachment
) {
return $config;
}
$attachment = $config['background_image_id'];
$fullRange = ['start_percentage' => 0.0, 'end_percentage' => 100.0];
$crops = $attachment->cropVariants->keyBy('variant');
$config['background_image_id'] = [
'url' => $attachment->getTemporaryUrl(1440),
'crops' => collect(AttachmentCrop::VARIANTS)->mapWithKeys(
function (string $variant) use ($crops, $fullRange): array {
$crop = $crops->get($variant);
return [$variant => [
'crop_horizontal' => $crop?->crop_horizontal ?? $fullRange,
'crop_vertical' => $crop?->crop_vertical ?? $fullRange,
]];
}
)->all(),
];
return $config;
}
private function formatConfig(mixed $value, callable $formatAttachment): mixed
{
if ($value instanceof Attachment) {
return $formatAttachment($value);
}
if (! is_array($value)) {
return $value;
}
return array_map(
fn (mixed $item): mixed => $this->formatConfig($item, $formatAttachment),
$value
);
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Domains\Tenant\Resources\AdminApp;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Models\AttachmentCrop;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Tenant
*/
class WebsiteExtrasResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$websiteExtras = $this->websiteExtras->keyBy(
fn ($extra) => $extra->websiteTypeExtra->codigo
);
return [
'storefront_website_type' => $this->storefrontWebsiteType ? [
'codigo' => $this->storefrontWebsiteType->codigo,
'nombre' => $this->storefrontWebsiteType->nombre,
] : null,
'definitions' => $this->storefrontWebsiteType?->extras
->mapWithKeys(fn ($definition) => [
$definition->codigo => [
'codigo' => $definition->codigo,
'nombre' => $definition->nombre,
'descripcion' => $definition->descripcion,
'is_required' => $definition->is_required,
'is_enabled' => $websiteExtras
->get($definition->codigo)?->is_enabled,
'request_rules' => $definition->config_schema['request_rules'] ?? [],
],
]) ?? [],
'extras' => $websiteExtras->mapWithKeys(fn ($extra) => [
$extra->websiteTypeExtra->codigo => $this->formatConfig(
$extra->resolvedConfig(),
fn (Attachment $attachment): string => $attachment->key
),
]),
'resolved_extras' => $websiteExtras->mapWithKeys(fn ($extra) => [
$extra->websiteTypeExtra->codigo => $this->formatResolvedConfig($extra),
]),
];
}
private function formatResolvedConfig(mixed $extra): mixed
{
$config = $extra->resolvedConfig();
if (
$extra->websiteTypeExtra->codigo === 'heroConfig'
&& is_array($config)
&& ($config['background_image_id'] ?? null) instanceof Attachment
) {
$attachment = $config['background_image_id'];
$config['background_image_id'] = $this->formatHeroAttachment($attachment);
}
return $this->formatConfig(
$config,
fn (Attachment $attachment): string => $attachment->getTemporaryUrl(1440)
);
}
/**
* @return array{url: string, crops: array<string, array<string, array<string, float>>>}
*/
private function formatHeroAttachment(Attachment $attachment): array
{
$fullRange = ['start_percentage' => 0.0, 'end_percentage' => 100.0];
$crops = $attachment->cropVariants->keyBy('variant');
return [
'url' => $attachment->getTemporaryUrl(1440),
'crops' => collect(AttachmentCrop::VARIANTS)->mapWithKeys(
function (string $variant) use ($crops, $fullRange): array {
$crop = $crops->get($variant);
return [$variant => [
'crop_horizontal' => $crop?->crop_horizontal ?? $fullRange,
'crop_vertical' => $crop?->crop_vertical ?? $fullRange,
]];
}
)->all(),
];
}
private function formatConfig(mixed $value, callable $formatAttachment): mixed
{
if ($value instanceof Attachment) {
return $formatAttachment($value);
}
if (! is_array($value)) {
return $value;
}
return array_map(
fn (mixed $item): mixed => $this->formatConfig($item, $formatAttachment),
$value
);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Tenant\Resources;
use App\Domains\Tenant\Enums\CartEditingPolicy;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin CartEditingPolicy */
class CartEditingPolicyResource extends JsonResource
{
/** @return array<string, bool|string> */
public function toArray(Request $request): array
{
return [
'code' => $this->resource->value,
'allow_modify' => $this->resource->allowsModification(),
'allow_delete' => $this->resource->allowsRemoval(),
'allow_update_quantity' => $this->resource->allowsQuantityChanges(),
'allow_update_variant' => $this->resource->allowsVariantChanges(),
];
}
}

View File

@@ -0,0 +1,228 @@
<?php
namespace App\Domains\Tenant\Resources;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Models\AttachmentCrop;
use App\Domains\Catalog\Models\Category;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Services\EventDateInfoFormatter;
use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* @mixin Tenant
*/
class TenantResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$eventDateChanges = $this->relationLoaded('eventDateChanges')
? $this->eventDateChanges
: collect();
return [
'id' => $this->id,
'client_id' => $this->client_id,
'codigo' => $this->codigo,
'nombre' => $this->nombre,
'dominio' => $this->dominio,
'base_path' => $this->base_path,
'site_title' => $this->site_title ?? 'ShopitFront',
'asset_url' => config('filesystems.disks.s3.url'),
'address' => $this->address,
'phone' => $this->phone,
'favicon' => $this->favicon?->getTemporaryUrl(1440),
'primary_color' => $this->primary_color,
'secondary_color' => $this->secondary_color,
'danger_color' => $this->danger_color,
'success_color' => $this->success_color,
'header_bg_color' => $this->header_bg_color,
'footer_bg_color' => $this->footer_bg_color,
'admin_website_type_code' => $this->admin_website_type_code,
'storefront_website_type_code' => $this->storefront_website_type_code,
'header_type' => $this->storefrontWebsiteType?->header_type ?? 'standard',
'event_date_text' => $this->event_date_text,
'event' => $this->whenLoaded('eventDates', fn () => $this->event_title === null
? null
: [
'title' => $this->event_title,
'location' => $this->event_location,
'dates' => $this->eventDates
->filter(fn (EventDate $eventDate): bool => $eventDate->rescheduled_to_event_date_id === null)
->map(fn (EventDate $eventDate): array => [
'id' => $eventDate->id,
'date' => $eventDate->date->format('Y-m-d'),
'time_start' => $eventDate->time_start,
'time_end' => $eventDate->time_end,
'info_text' => app(EventDateInfoFormatter::class)->format(
$eventDate,
$eventDateChanges,
),
'isCanceled' => $eventDate->suspended_at !== null,
])->values(),
]),
'extras' => $this->whenLoaded(
'websiteExtras',
fn () => $this->websiteExtras
->filter(fn ($extra) => $extra->is_enabled)
->mapWithKeys(fn ($extra) => [
$extra->websiteTypeExtra->codigo => $this->formatExtraConfig(
$extra->resolvedConfig()
),
])
),
// 1 day
'header_logo' => $this->headerLogo?->getTemporaryUrl(1440),
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440),
'header_bg_image' => $this->headerBackgroundImage?->getTemporaryUrl(1440),
'footer_bg_image' => $this->footerBackgroundImage?->getTemporaryUrl(1440),
'search_product_layout' => $this->search_product_layout->value,
'search_group_layout' => $this->search_group_layout->value,
'search_items_per_page' => $this->search_items_per_page,
'display_categories' => $this->display_categories,
'display_seach_bar' => $this->display_seach_bar,
'display_cart' => $this->display_cart,
'cart_editing_policy' => CartEditingPolicyResource::make($this->cart_editing_policy),
'checkout_editing_policy' => CartEditingPolicyResource::make($this->checkout_editing_policy),
'display_cart_item_images' => $this->display_cart_item_images,
'scanner_category_validation_enabled' => $this->scanner_category_validation_enabled,
'allow_ticket_refund' => $this->allow_ticket_refund,
'allow_ticket_total_refund' => $this->allow_ticket_total_refund,
'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund,
'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage,
'social_media' => $this->whenLoaded(
'socialMedia',
fn () => $this->socialMedia
->map(fn ($socialMedia) => [
'code' => $socialMedia->code,
'icon' => $socialMedia->icon,
'name' => $socialMedia->name,
'url' => $socialMedia->pivot->url,
])
->values()
),
'menues' => $this->whenLoaded(
'menues',
fn () => $this->menuTree($this->menues)
),
'categories' => $this->whenLoaded(
'categories',
fn () => $this->categoryTree($this->categories)
),
];
}
private function formatExtraConfig(mixed $value): mixed
{
if ($value instanceof Attachment) {
$crops = $value->cropVariants->keyBy('variant');
$desktop = $crops->get(AttachmentCrop::DESKTOP)?->croppedAttachment ?? $value;
$mobile = $crops->get(AttachmentCrop::MOBILE)?->croppedAttachment ?? $desktop;
if ($crops->isEmpty()) {
return $value->getTemporaryUrl(1440);
}
return [
'desktop' => $desktop->getTemporaryUrl(1440),
'mobile' => $mobile->getTemporaryUrl(1440),
];
}
if (! is_array($value)) {
return $value;
}
foreach ($value as $key => $item) {
$value[$key] = $this->formatExtraConfig($item);
}
return $value;
}
/**
* @param Collection<int, Category> $categories
* @return Collection<int, array<string, mixed>>
*/
private function categoryTree(Collection $categories): Collection
{
$categoryIds = $categories->pluck('id')->flip();
$childrenByParent = $categories
->filter(fn (Category $category) => $category->categoria_id !== null
&& $categoryIds->has($category->categoria_id))
->groupBy('categoria_id');
$formatCategory = function (Category $category) use (&$formatCategory, $childrenByParent): array {
return [
'id' => $category->id,
'nombre' => $category->nombre,
'subcategories' => $childrenByParent
->get($category->id, collect())
->map($formatCategory)
->values(),
];
};
return $categories
->filter(fn (Category $category) => $category->categoria_id === null
|| ! $categoryIds->has($category->categoria_id))
->map($formatCategory)
->values();
}
/**
* @param Collection<int, Menu> $menus
* @return Collection<int, array<string, mixed>>
*/
private function menuTree(Collection $menus): Collection
{
$menuCodes = $menus->pluck('code')->flip();
$childrenByParent = $menus
->filter(fn (Menu $menu) => $menu->parent_menu_code !== null)
->groupBy('parent_menu_code');
$formatMenu = function (Menu $menu) use (&$formatMenu, $childrenByParent): array {
$formatted = [
'id' => $menu->id,
'code' => $menu->code,
'label' => $menu->label,
'parent_menu_code' => $menu->parent_menu_code,
'content_type' => $menu->content_type,
'route' => $menu->route,
];
if ($menu->pivot?->static_content !== null) {
$formatted['static_content'] = $menu->pivot->static_content;
}
$formatted['submenues'] = $childrenByParent
->get($menu->code, collect())
->sortBy(
fn (Menu $submenu) => Str::lower(Str::ascii($submenu->label)),
SORT_NATURAL
)
->map($formatMenu)
->values();
return $formatted;
};
return $menus
->filter(fn (Menu $menu) => $menu->parent_menu_code === null
|| ! $menuCodes->has($menu->parent_menu_code))
->sortBy(
fn (Menu $menu) => Str::lower(Str::ascii($menu->label)),
SORT_NATURAL
)
->map($formatMenu)
->values();
}
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Domains\Tenant\Services;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Tenant\Models\AdminWebsiteType;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class AdminWebsiteTypeService
{
public function __construct(
protected AttachmentService $attachmentService,
) {}
/**
* Create a website type and store its logos.
*
* @param array<string, mixed> $data
*/
public function create(array $data): AdminWebsiteType
{
return $this->save(new AdminWebsiteType, $data);
}
/**
* Create or update a website type and replace its logos when provided.
*
* @param array<string, mixed> $attributes
* @param array<string, mixed> $values
*/
public function updateOrCreate(array $attributes, array $values = []): AdminWebsiteType
{
/** @var AdminWebsiteType $websiteType */
$websiteType = AdminWebsiteType::query()->firstOrNew($attributes);
return $this->save($websiteType, [...$attributes, ...$values]);
}
/**
* @param array<string, mixed> $data
*/
private function save(AdminWebsiteType $websiteType, array $data): AdminWebsiteType
{
return DB::transaction(function () use ($websiteType, $data): AdminWebsiteType {
$previousLogos = [];
$attachmentFields = [
'site_logo' => ['relation' => 'siteLogo', 'column' => 'site_logo'],
'footer_logo' => ['relation' => 'footerLogo', 'column' => 'footer_logo'],
'favicon' => ['relation' => 'favicon', 'column' => 'favicon_id'],
];
foreach ($attachmentFields as $field => $attachmentField) {
if (! array_key_exists($field, $data)) {
continue;
}
$logo = $data[$field];
$previousLogos[$field] = $websiteType->exists
? $websiteType->{$attachmentField['relation']}()->first()
: null;
unset($data[$field]);
$attachment = null;
if ($logo) {
$attachment = is_string($logo) && Str::isUuid($logo)
? Attachment::query()->where('key', $logo)->first()
: $this->attachmentService->store($logo, 'admin-website-types');
}
$data[$attachmentField['column']] = $attachment?->id;
}
$websiteType->fill($data)->save();
foreach ($previousLogos as $previousLogo) {
if (
$previousLogo instanceof Attachment
&& $previousLogo->id !== $websiteType->site_logo
&& $previousLogo->id !== $websiteType->footer_logo
&& $previousLogo->id !== $websiteType->favicon_id
&& ! $this->isReferencedByBrand($previousLogo)
) {
$this->attachmentService->delete($previousLogo);
}
}
return $websiteType;
});
}
private function isReferencedByBrand(Attachment $attachment): bool
{
return Tenant::query()->where('favicon_id', $attachment->id)->exists()
|| AdminWebsiteType::query()
->where(function ($query) use ($attachment): void {
$query
->where('site_logo', $attachment->id)
->orWhere('footer_logo', $attachment->id)
->orWhere('favicon_id', $attachment->id);
})
->exists();
}
}

View File

@@ -0,0 +1,181 @@
<?php
namespace App\Domains\Tenant\Services;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteExtra;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
class TenantInformationService
{
private const DEFAULT_RELATIONS = [
'headerLogo',
'footerLogo',
'favicon',
'headerBackgroundImage',
'footerBackgroundImage',
'socialMedia',
'websiteExtras.websiteTypeExtra',
'eventDates',
];
/**
* @param array<int|string, mixed> $relations
*/
public function load(Tenant $tenant, array $relations = []): Tenant
{
$this->loadMany(new EloquentCollection([$tenant]), $relations);
return $tenant;
}
/**
* @param EloquentCollection<int, Tenant> $tenants
* @param array<int|string, mixed> $relations
* @return EloquentCollection<int, Tenant>
*/
public function loadMany(
EloquentCollection $tenants,
array $relations = []
): EloquentCollection {
$tenants->loadMissing([
...self::DEFAULT_RELATIONS,
...$relations,
]);
$websiteExtras = $tenants
->flatMap(fn (Tenant $tenant): Collection => $tenant->websiteExtras);
$this->resolveWebsiteExtraAttachments($websiteExtras);
return $tenants;
}
/**
* Replace attachment IDs with their models in each extra's resolved config.
*
* @param Collection<int, WebsiteExtra> $websiteExtras
*/
private function resolveWebsiteExtraAttachments(Collection $websiteExtras): void
{
$attachmentIds = $websiteExtras
->flatMap(function (WebsiteExtra $extra): array {
$ids = [];
foreach ($this->attachmentTransformPaths($extra) as $path) {
$ids = [
...$ids,
...$this->valuesAtPath($extra->config, $this->pathSegments($path)),
];
}
return $ids;
})
->filter(fn (mixed $id): bool => is_int($id) || ctype_digit((string) $id))
->map(fn (mixed $id): int => (int) $id)
->unique()
->values();
$attachments = Attachment::query()
->whereIn('id', $attachmentIds)
->with('cropVariants.croppedAttachment')
->get()
->keyBy('id');
foreach ($websiteExtras as $extra) {
$config = $extra->config;
foreach ($this->attachmentTransformPaths($extra) as $path) {
$config = $this->replaceAtPath(
$config,
$this->pathSegments($path),
fn (mixed $attachmentId): ?Attachment => $attachments->get((int) $attachmentId)
);
}
$extra->setResolvedConfig($config);
}
}
/**
* @return array<int, string>
*/
private function attachmentTransformPaths(WebsiteExtra $extra): array
{
return collect($extra->websiteTypeExtra->config_schema['transforms'] ?? [])
->filter(
fn (mixed $transform): bool => is_array($transform)
&& ($transform['handler'] ?? null) === 'attachment'
)
->keys()
->all();
}
/**
* @return array<int, string>
*/
private function pathSegments(string $path): array
{
$path = ltrim($path, '$');
$path = ltrim($path, '.');
return $path === '' ? [] : explode('.', $path);
}
/**
* @return array<int, mixed>
*/
private function valuesAtPath(mixed $value, array $segments): array
{
if ($segments === []) {
return [$value];
}
if (! is_array($value)) {
return [];
}
$segment = array_shift($segments);
if ($segment === '*') {
return collect($value)
->flatMap(fn (mixed $item): array => $this->valuesAtPath($item, $segments))
->all();
}
if (! array_key_exists($segment, $value)) {
return [];
}
return $this->valuesAtPath($value[$segment], $segments);
}
private function replaceAtPath(mixed $value, array $segments, callable $replace): mixed
{
if ($segments === []) {
return $value === null ? null : $replace($value);
}
if (! is_array($value)) {
return $value;
}
$segment = array_shift($segments);
if ($segment === '*') {
foreach ($value as $key => $item) {
$value[$key] = $this->replaceAtPath($item, $segments, $replace);
}
return $value;
}
if (array_key_exists($segment, $value)) {
$value[$segment] = $this->replaceAtPath($value[$segment], $segments, $replace);
}
return $value;
}
}

View File

@@ -0,0 +1,197 @@
<?php
namespace App\Domains\Tenant\Services;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class TenantService
{
public function __construct(
protected AttachmentService $attachmentService,
protected WebsiteExtraService $websiteExtraService,
) {}
/**
* Create a new tenant and store its logos.
*
* @param array<string, mixed> $data
*/
public function create(array $data): Tenant
{
return DB::transaction(function () use ($data): Tenant {
$headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null;
$favicon = $data['favicon'] ?? null;
$headerBackgroundImage = $data['header_bg_image'] ?? null;
$footerBackgroundImage = $data['footer_bg_image'] ?? null;
$socialMedia = $data['social_media'] ?? [];
$extras = $data['extras'] ?? [];
unset(
$data['header_logo'],
$data['footer_logo'],
$data['favicon'],
$data['header_bg_image'],
$data['footer_bg_image'],
$data['social_media'],
$data['extras'],
);
$headerAttachmentId = null;
if ($headerLogo) {
$attachment = Str::isUuid($headerLogo)
? Attachment::query()->where('key', $headerLogo)->first()
: $this->attachmentService->store($headerLogo, 'tenants');
if ($attachment) {
$headerAttachmentId = $attachment->id;
}
}
$footerAttachmentId = null;
if ($footerLogo) {
$attachment = Str::isUuid($footerLogo)
? Attachment::query()->where('key', $footerLogo)->first()
: $this->attachmentService->store($footerLogo, 'tenants');
if ($attachment) {
$footerAttachmentId = $attachment->id;
}
}
$data['header_logo_id'] = $headerAttachmentId;
$data['footer_logo_id'] = $footerAttachmentId;
$data['favicon_id'] = $this->storeTenantImage($favicon);
$data['header_bg_image_id'] = $this->storeTenantImage($headerBackgroundImage);
$data['footer_bg_image_id'] = $this->storeTenantImage($footerBackgroundImage);
/** @var Tenant $tenant */
$tenant = Tenant::query()->create($data);
$this->syncSocialMedia($tenant, $socialMedia);
$this->websiteExtraService->createForTenant($tenant, $extras);
return $tenant;
});
}
/**
* Update an existing tenant and store new logos if uploaded.
*
* @param array<string, mixed> $data
*/
public function update(Tenant $tenant, array $data): Tenant
{
return DB::transaction(function () use ($tenant, $data): Tenant {
$hasHeaderLogoKey = array_key_exists('header_logo', $data);
$hasFooterLogoKey = array_key_exists('footer_logo', $data);
$hasFaviconKey = array_key_exists('favicon', $data);
$hasHeaderBackgroundImageKey = array_key_exists('header_bg_image', $data);
$hasFooterBackgroundImageKey = array_key_exists('footer_bg_image', $data);
$hasSocialMediaKey = array_key_exists('social_media', $data);
$headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null;
$favicon = $data['favicon'] ?? null;
$headerBackgroundImage = $data['header_bg_image'] ?? null;
$footerBackgroundImage = $data['footer_bg_image'] ?? null;
$socialMedia = $data['social_media'] ?? [];
unset(
$data['header_logo'],
$data['footer_logo'],
$data['favicon'],
$data['header_bg_image'],
$data['footer_bg_image'],
$data['social_media']
);
$tenant->fill($data);
if ($hasHeaderLogoKey) {
if ($headerLogo) {
$attachment = Str::isUuid($headerLogo)
? Attachment::query()->where('key', $headerLogo)->first()
: $this->attachmentService->store($headerLogo, 'tenants');
if ($attachment) {
$tenant->header_logo_id = $attachment->id;
} else {
$tenant->header_logo_id = null;
}
} else {
$tenant->header_logo_id = null;
}
}
if ($hasFooterLogoKey) {
if ($footerLogo) {
$attachment = Str::isUuid($footerLogo)
? Attachment::query()->where('key', $footerLogo)->first()
: $this->attachmentService->store($footerLogo, 'tenants');
if ($attachment) {
$tenant->footer_logo_id = $attachment->id;
} else {
$tenant->footer_logo_id = null;
}
} else {
$tenant->footer_logo_id = null;
}
}
if ($hasFaviconKey) {
$tenant->favicon_id = $this->storeTenantImage($favicon);
}
if ($hasHeaderBackgroundImageKey) {
$tenant->header_bg_image_id = $this->storeTenantImage($headerBackgroundImage);
}
if ($hasFooterBackgroundImageKey) {
$tenant->footer_bg_image_id = $this->storeTenantImage($footerBackgroundImage);
}
$tenant->save();
if ($hasSocialMediaKey) {
$this->syncSocialMedia($tenant, $socialMedia);
}
return $tenant;
});
}
/**
* @param array<int, array{code: string, url: string, orden?: int}> $socialMedia
*/
private function syncSocialMedia(Tenant $tenant, array $socialMedia): void
{
$associations = [];
foreach (array_values($socialMedia) as $index => $item) {
$associations[$item['code']] = [
'url' => $item['url'],
'orden' => $item['orden'] ?? $index,
];
}
$tenant->socialMedia()->sync($associations);
$tenant->unsetRelation('socialMedia');
}
private function storeTenantImage(mixed $image): ?int
{
if (! $image) {
return null;
}
$attachment = is_string($image) && Str::isUuid($image)
? Attachment::query()->where('key', $image)->first()
: $this->attachmentService->store($image, 'tenants');
return $attachment?->id;
}
}

View File

@@ -0,0 +1,419 @@
<?php
namespace App\Domains\Tenant\Services;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Shared\Rules\CroppedImageOrBase64Rule;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteExtra;
use App\Domains\Tenant\Models\StorefrontWebsiteType;
use App\Domains\Tenant\Models\StorefrontWebsiteTypeExtra;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
class WebsiteExtraService
{
public function __construct(protected AttachmentService $attachmentService) {}
/**
* Build the request rules declared by the selected website type.
*
* @return array<string, mixed>
*/
public function requestRules(?string $websiteTypeCode): array
{
if (! is_string($websiteTypeCode) || $websiteTypeCode === '') {
return [
'extras' => ['prohibited'],
];
}
$definitions = $this->definitionsFor($websiteTypeCode);
$allowedCodes = $definitions->pluck('codigo')->all();
$hasRequiredExtras = $definitions->contains(
fn (StorefrontWebsiteTypeExtra $definition): bool => $definition->is_required
);
$rules = [
'extras' => [
$hasRequiredExtras ? 'required' : 'sometimes',
'array',
function (string $attribute, mixed $value, \Closure $fail) use ($allowedCodes): void {
if (! is_array($value)) {
return;
}
$unknownCodes = array_diff(array_keys($value), $allowedCodes);
if ($unknownCodes !== []) {
$fail(
'The '.$attribute.' field contains extras not supported by the website type: '
.implode(', ', $unknownCodes).'.'
);
}
},
],
];
foreach ($definitions as $definition) {
$schemaRules = $definition->config_schema['request_rules'] ?? [];
$rootRules = $this->compileRules($schemaRules['$'] ?? []);
$rootRules = array_values(array_filter(
$rootRules,
fn (mixed $rule): bool => ! in_array($rule, ['required', 'sometimes'], true)
));
array_unshift($rootRules, $definition->is_required ? 'required' : 'sometimes');
$rules["extras.{$definition->codigo}"] = $rootRules;
foreach ($schemaRules as $path => $pathRules) {
if ($path === '$') {
continue;
}
$rules[$this->requestAttribute($definition->codigo, $path)] = $this->compileRules($pathRules);
}
}
return $rules;
}
/**
* Transform and persist each extra selected for a tenant.
*
* @param array<string, mixed> $extras
*/
public function createForTenant(Tenant $tenant, array $extras): void
{
if ($extras === []) {
return;
}
$definitions = $this->definitionsFor((string) $tenant->storefront_website_type_code)
->keyBy('codigo');
foreach ($extras as $name => $config) {
/** @var StorefrontWebsiteTypeExtra|null $definition */
$definition = $definitions->get($name);
if (! $definition) {
throw ValidationException::withMessages([
'extras' => ["The extra {$name} is not supported by the selected website type."],
]);
}
$requestRoot = "extras.{$definition->codigo}";
$transformedConfig = $this->applyTransforms(
$tenant,
$definition,
$config,
$requestRoot
);
$tenant->websiteExtras()->create([
'website_type_extra_id' => $definition->id,
'config' => $transformedConfig,
]);
}
$tenant->unsetRelation('websiteExtras');
}
/**
* Build request rules for one extra addressed by its stable code.
*
* @return array<string, mixed>
*/
public function requestRulesForExtra(Tenant $tenant, string $extraCode): array
{
$definition = $this->definitionForTenant($tenant, $extraCode);
$schemaRules = $definition->config_schema['request_rules'] ?? [];
$rootRules = $this->compileRules($schemaRules['$'] ?? []);
$rootRules = array_values(array_filter(
$rootRules,
fn (mixed $rule): bool => ! in_array($rule, ['required', 'sometimes'], true)
));
array_unshift($rootRules, 'required');
$rules = ['config' => $rootRules];
foreach ($schemaRules as $path => $pathRules) {
if ($path === '$') {
continue;
}
$rules[$this->configAttribute('config', $path)] = $this->compileRules($pathRules);
}
return $rules;
}
public function updateForTenant(Tenant $tenant, string $extraCode, mixed $config): WebsiteExtra
{
$definition = $this->definitionForTenant($tenant, $extraCode);
return DB::transaction(function () use ($tenant, $definition, $config): WebsiteExtra {
$transformedConfig = $this->applyTransforms($tenant, $definition, $config, 'config');
return $tenant->websiteExtras()->updateOrCreate(
['website_type_extra_id' => $definition->id],
['config' => $transformedConfig]
);
});
}
public function toggleForTenant(Tenant $tenant, string $extraCode): WebsiteExtra
{
$definition = $this->definitionForTenant($tenant, $extraCode);
return DB::transaction(function () use ($tenant, $definition): WebsiteExtra {
$websiteExtra = $tenant->websiteExtras()
->where('website_type_extra_id', $definition->id)
->lockForUpdate()
->first();
if (! $websiteExtra) {
return $tenant->websiteExtras()->create([
'website_type_extra_id' => $definition->id,
'config' => [],
'is_enabled' => true,
]);
}
$websiteExtra->update([
'is_enabled' => ! $websiteExtra->is_enabled,
]);
return $websiteExtra;
});
}
public function definitionForTenant(Tenant $tenant, string $extraCode): StorefrontWebsiteTypeExtra
{
return StorefrontWebsiteTypeExtra::query()
->where('storefront_website_type_code', $tenant->storefront_website_type_code)
->where('codigo', $extraCode)
->firstOrFail();
}
/**
* @return Collection<int, StorefrontWebsiteTypeExtra>
*/
private function definitionsFor(string $websiteTypeCode): Collection
{
$websiteType = StorefrontWebsiteType::query()
->where('codigo', $websiteTypeCode)
->with('extras')
->first();
return $websiteType?->extras ?? collect();
}
/**
* @param string|array<int, mixed> $rules
* @return array<int, mixed>
*/
private function compileRules(string|array $rules): array
{
$compiled = is_string($rules) ? explode('|', $rules) : $rules;
return array_map(
fn (mixed $rule): mixed => match ($rule) {
'image_or_base64' => new ImageOrBase64Rule,
'cropped_image_or_base64' => new CroppedImageOrBase64Rule,
default => $rule,
},
$compiled
);
}
private function requestAttribute(string $extraCode, string $path): string
{
return $this->configAttribute("extras.{$extraCode}", $path);
}
private function configAttribute(string $root, string $path): string
{
if ($path === '$') {
return $root;
}
if (str_starts_with($path, '$.')) {
$path = substr($path, 2);
}
return "{$root}.{$path}";
}
private function applyTransforms(
Tenant $tenant,
StorefrontWebsiteTypeExtra $definition,
mixed $config,
string $requestRoot
): mixed {
foreach ($definition->config_schema['transforms'] ?? [] as $path => $transform) {
$segments = $this->pathSegments($path);
$config = $this->transformAtPath(
$config,
$segments,
fn (mixed $value): mixed => $this->transformValue(
$tenant,
$definition,
$path,
$value,
$transform,
$requestRoot
)
);
}
return $config;
}
/**
* @return array<int, string>
*/
private function pathSegments(string $path): array
{
$path = ltrim($path, '$');
$path = ltrim($path, '.');
return $path === '' ? [] : explode('.', $path);
}
private function transformAtPath(mixed $value, array $segments, callable $transform): mixed
{
if ($segments === []) {
return $transform($value);
}
if (! is_array($value)) {
return $value;
}
$segment = array_shift($segments);
if ($segment === '*') {
foreach ($value as $key => $item) {
$value[$key] = $this->transformAtPath($item, $segments, $transform);
}
return $value;
}
if (array_key_exists($segment, $value)) {
$value[$segment] = $this->transformAtPath($value[$segment], $segments, $transform);
}
return $value;
}
/**
* @param array<string, mixed> $transform
*/
private function transformValue(
Tenant $tenant,
StorefrontWebsiteTypeExtra $definition,
string $path,
mixed $value,
array $transform,
string $requestRoot
): mixed {
if ($value === null) {
return null;
}
if (($transform['handler'] ?? null) !== 'attachment') {
throw new InvalidArgumentException(
"Unsupported transform handler for {$definition->codigo}: ".($transform['handler'] ?? 'null')
);
}
$crops = $this->cropVariants($value);
$image = is_array($value) ? $value['image'] ?? null : $value;
if (is_string($image) && Str::isUuid($image)) {
$attachment = Attachment::query()->where('key', $image)->first();
if (! $attachment) {
throw ValidationException::withMessages([
$this->configAttribute($requestRoot, $path) => [
'The selected attachment does not exist.',
],
]);
}
if ($crops !== null) {
$attachment = $this->attachmentService->updateImageCropVariants(
$attachment,
$crops,
);
}
} elseif ($crops !== null) {
$attachment = $this->attachmentService->storeCroppedImageVariants(
$image,
"tenants/{$tenant->codigo}/extras/{$definition->codigo}",
$crops,
);
} else {
$attachment = $this->attachmentService->store(
$image,
"tenants/{$tenant->codigo}/extras/{$definition->codigo}"
);
}
$expectedType = $transform['attachment_type'] ?? null;
if (
is_string($expectedType)
&& $attachment->type instanceof AttachmentType
&& $attachment->type->value !== $expectedType
) {
throw ValidationException::withMessages([
$this->configAttribute($requestRoot, $path) => [
"The attachment must be of type {$expectedType}.",
],
]);
}
return $attachment->id;
}
/**
* Normalize the current variants payload and the original single-crop contract.
*
* @return array<string, array<string, mixed>>|null
*/
private function cropVariants(mixed $value): ?array
{
if (! is_array($value)) {
return null;
}
if (isset($value['crops']) && is_array($value['crops'])) {
return $value['crops'];
}
$horizontal = $value['crop_horizontal'] ?? null;
$vertical = $value['crop_vertical'] ?? null;
if (! is_array($horizontal) || ! is_array($vertical)) {
return null;
}
$crop = [
'crop_horizontal' => $horizontal,
'crop_vertical' => $vertical,
];
return [
'desktop' => $crop,
'mobile' => $crop,
];
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Domains\Tenant\Support;
class TenantDomainNormalizer
{
public static function hasValue(mixed $domain): bool
{
return is_string($domain) && trim(urldecode($domain)) !== '';
}
public static function normalize(mixed $domain): ?string
{
if (! is_string($domain)) {
return null;
}
$decodedDomain = trim(urldecode($domain));
if ($decodedDomain === '') {
return null;
}
$candidate = str_contains($decodedDomain, '://')
? $decodedDomain
: "//{$decodedDomain}";
$host = parse_url($candidate, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return null;
}
return strtolower($host);
}
public static function normalizeTenantKey(mixed $domain, mixed $path = null): ?string
{
$host = self::normalize($domain);
if ($host === null) {
return null;
}
$path ??= self::pathFromDomain($domain);
$normalizedPath = self::normalizePath($path);
if ($normalizedPath === null) {
return null;
}
return $host.($normalizedPath === '/' ? '' : $normalizedPath);
}
public static function pathFromDomain(mixed $domain): ?string
{
if (! is_string($domain)) {
return null;
}
$decodedDomain = trim(urldecode($domain));
if ($decodedDomain === '') {
return null;
}
$candidate = str_contains($decodedDomain, '://')
? $decodedDomain
: "//{$decodedDomain}";
return self::normalizePath(parse_url($candidate, PHP_URL_PATH) ?: '/');
}
public static function normalizePath(mixed $path): ?string
{
if (! is_string($path)) {
return null;
}
$path = trim($path);
if ($path === '' || $path === '/') {
return '/';
}
$path = parse_url(str_starts_with($path, '/') ? $path : "/{$path}", PHP_URL_PATH);
if (! is_string($path)) {
return null;
}
$path = preg_replace('#/+#', '/', $path);
if (! is_string($path)) {
return null;
}
$segments = array_filter(explode('/', $path), static fn (string $segment): bool => $segment !== '');
foreach ($segments as $segment) {
if ($segment === '.' || $segment === '..') {
return null;
}
}
return '/'.implode('/', $segments);
}
/** @return list<string> */
public static function tenantKeyCandidates(mixed $domain, mixed $path): array
{
$host = self::normalize($domain);
if ($host === null) {
return [];
}
return array_map(
static fn (string $basePath): string => $host.($basePath === '/' ? '' : $basePath),
self::basePathCandidates($path),
);
}
/** @return list<string> */
public static function basePathCandidates(mixed $path): array
{
$normalizedPath = self::normalizePath($path);
if ($normalizedPath === null) {
return [];
}
$segments = array_values(array_filter(
explode('/', $normalizedPath),
static fn (string $segment): bool => $segment !== '',
));
$candidates = [];
while ($segments !== []) {
$candidates[] = '/'.implode('/', $segments);
array_pop($segments);
}
$candidates[] = '/';
return $candidates;
}
}

View File

@@ -0,0 +1,33 @@
# Dominio Tenant
## Propósito
Es la raíz del modelo multi-tenant. Gestiona organizaciones/sitios, tipos de web, redes sociales, logos y extras configurables por sitio.
## Modelo
- `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).
- `AdminWebsiteType`: dominios, marca e integraciones compartidas del admin y scanner.
- `StorefrontWebsiteType`: tipo de storefront que define los extras admitidos.
- `StorefrontWebsiteTypeExtra`: definición de un extra y su configuración admitida.
- `WebsiteExtra`: valor resuelto y estado del extra para un tenant.
- `SocialMedia`: catálogo de redes sociales asociables.
## Servicios
- `TenantService`: crea y actualiza tenants, incluyendo sus recursos asociados.
- `TenantInformationService`: carga un tenant y las relaciones requeridas por cada contexto.
- `AdminWebsiteTypeService`: crea o actualiza los tipos de admin y su marca.
- `WebsiteExtraService`: construye reglas dinámicas, crea, actualiza y habilita/deshabilita extras.
- `TenantDomainNormalizer`: normaliza dominios antes de resolver el tenant.
El par `(dominio, base_path)` es único. Un mismo dominio puede alojar el tenant raíz y otros tenants en prefijos diferentes. El bootstrap compara segmentos completos del path y selecciona el prefijo más específico.
## Endpoints
- Recurso REST público/administrativo `/tenants`.
- Bajo `/v1/adminapp/tenant/website-extras`, con autenticación y contexto de tenant: consulta general, detalle, actualización y activación/desactivación.
## Dependencias y reglas
Usa `Attachable` para logos y archivos. Es referenciado por casi todos los dominios para aislamiento. El `codigo` es clave de ruta y clave foránea heredada; no debe sustituirse por `id` sin una migración integral.

View File

@@ -0,0 +1,13 @@
<?php
use App\Domains\Tenant\Controllers\AdminApp\WebsiteExtraController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('website-extras', [WebsiteExtraController::class, 'show']);
Route::get('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'showExtra']);
Route::put('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'update']);
Route::patch('website-extras/{websiteExtraCode}/toggle', [WebsiteExtraController::class, 'toggle']);
});

View File

@@ -0,0 +1,8 @@
<?php
use App\Domains\Tenant\Controllers\TenantController;
use Illuminate\Support\Facades\Route;
Route::apiResource('tenants', TenantController::class);
require __DIR__.'/adminapp.php';