23 Commits

Author SHA1 Message Date
cf2beb2ff5 feat(auth): implement AdminApp login functionality with controller, request, routes, and tests 2026-07-31 10:47:55 -03:00
484fa204b3 feat(tenant): add BootstrapTenantController and corresponding tests for tenant bootstrapping functionality 2026-07-31 10:36:46 -03:00
6dd057874a feat(website-extras): add showExtra method and resource for individual website extras, update routes and tests 2026-07-31 09:51:27 -03:00
40cbac17c6 feat(website-extras): implement toggle functionality for website extras and update related tests 2026-07-31 09:47:56 -03:00
45e56c43a6 feat(website-extras): add 'is_enabled' column to websites_extras table and update model and tests 2026-07-31 09:38:30 -03:00
cbb7b1c3a1 feat(database): remove 'database_rules' from WebsiteTypeExtra configurations and related tests 2026-07-31 09:28:12 -03:00
6b3a12b624 feat(website-extras): refactor WebsiteExtra management to use 'codigo' for identification, update routes, requests, and services 2026-07-31 09:27:35 -03:00
40a7dece11 feat(authorization): implement WebsiteExtra management with controller, request, resource, service, routes, middleware, and tests 2026-07-31 09:19:25 -03:00
5ec65ce3d3 fix(authorization): rename pivot table from 'menues_roles' to 'roles_menues' and update related references 2026-07-31 08:35:45 -03:00
b7e1332b8c feat(authorization): refactor role management to use RoleCode enum, update migrations, seeders, and tests 2026-07-31 08:32:00 -03:00
a087a511e0 feat(authorization): filter tenant menus by user roles in bootstrap process 2026-07-31 08:23:18 -03:00
a20b1713d3 feat(authorization): establish many-to-many relationship between roles and menus, add migration and seeder updates 2026-07-30 16:54:25 -03:00
d56d387933 feat(authorization): add role and tenant codes to users, implement validation logic, and create tests 2026-07-30 16:37:37 -03:00
cba32e90e7 feat(authorization): implement AuthorizationSeeder with roles and permissions, add tests 2026-07-30 16:37:37 -03:00
a674bcfabc feat(authorization): add roles and permissions models, migration, and tests 2026-07-30 16:37:20 -03:00
ace3f136e4 refactor(menu): add admin menu items and corresponding test cases 2026-07-30 16:36:43 -03:00
2cc72be850 refactor(website): add 'dates_text' field to event configuration and update related tests 2026-07-29 16:23:24 -03:00
8e3ee7eff6 refactor(tenant): introduce TenantInformationService for loading tenant data and resolving website extras 2026-07-29 15:48:01 -03:00
27d4f9fac6 feat(tenant): implement website extras functionality and validation in tenant creation 2026-07-29 15:16:33 -03:00
9e3163b74f refactor(website): update relationships and migration to use 'website_type_code' instead of 'website_type' 2026-07-29 15:03:31 -03:00
b9ad2e589f refactor(tenant): remove legacy presentation configuration and related carousel images 2026-07-29 14:54:57 -03:00
5416c0fd61 feat(seeder): add WebsiteTypeSeeder and update DatabaseSeeder to include it 2026-07-29 14:42:03 -03:00
04bf03fc2e feat(website): add website type and extras models with migration and tests 2026-07-29 14:09:47 -03:00
67 changed files with 3729 additions and 583 deletions

View File

@@ -34,7 +34,7 @@
"response": []
},
{
"name": "Create Tenant (JSON)",
"name": "Create Tenant OnTicket (JSON)",
"request": {
"method": "POST",
"header": [
@@ -51,7 +51,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"codigo\": \"acme\",\n \"nombre\": \"Acme\",\n \"dominio\": \"acme.test\",\n \"primary_color\": \"#111111\",\n \"secondary_color\": \"#222222\",\n \"danger_color\": \"#ff0000\",\n \"header_bg_color\": \"#333333\",\n \"footer_bg_color\": \"#333333\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n}"
"raw": "{\n \"codigo\": \"festival-demo\",\n \"nombre\": \"Festival Demo\",\n \"dominio\": \"festival-demo.test\",\n \"primary_color\": \"#111111\",\n \"secondary_color\": \"#222222\",\n \"danger_color\": \"#ff0000\",\n \"success_color\": \"#00aa55\",\n \"header_bg_color\": \"#333333\",\n \"footer_bg_color\": \"#333333\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"search_product_layout\": \"column_with_image\",\n \"search_group_layout\": \"paginated\",\n \"search_items_per_page\": 12,\n \"website_type_code\": \"onticket\",\n \"extras\": {\n \"heroConfig\": {\n \"title_html\": \"<h1>Festival Demo</h1>\",\n \"description_html\": \"<p>Una experiencia inolvidable</p>\",\n \"button_text\": \"Comprar entradas\",\n \"button_href\": \"/tickets\",\n \"background_image_id\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n },\n \"eventConfig\": {\n \"title\": \"Festival Demo\",\n \"location\": \"Buenos Aires\",\n \"dates_text\": \"10 y 11 de octubre de 2026\",\n \"dates\": [\n \"2026-10-10\",\n \"2026-10-11\"\n ]\n }\n }\n}"
},
"url": {
"raw": "{{base_url}}/api/tenants",
@@ -67,7 +67,7 @@
"response": []
},
{
"name": "Create Tenant (Form Data)",
"name": "Create Tenant ShopIt (Form Data)",
"request": {
"method": "POST",
"header": [
@@ -110,6 +110,11 @@
"value": "#ff0000",
"type": "text"
},
{
"key": "success_color",
"value": "#00aa55",
"type": "text"
},
{
"key": "header_bg_color",
"value": "#333333",
@@ -129,6 +134,32 @@
"key": "footer_logo",
"type": "file",
"src": []
},
{
"key": "search_product_layout",
"value": "column_with_image",
"type": "text"
},
{
"key": "search_group_layout",
"value": "paginated",
"type": "text"
},
{
"key": "search_items_per_page",
"value": "12",
"type": "text"
},
{
"key": "website_type_code",
"value": "shopit",
"type": "text"
},
{
"key": "extras[carousel][]",
"type": "file",
"src": [],
"description": "Se puede repetir esta key hasta 10 veces. También acepta una imagen base64 o el UUID de un attachment."
}
]
},

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Requests\AdminAppLoginRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\PasswordLoginService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class AdminAppLoginController extends Controller
{
public function __construct(
private readonly PasswordLoginService $passwordLoginService,
) {}
public function __invoke(AdminAppLoginRequest $request): JsonResponse
{
$credentials = $request->validated();
$user = $this->passwordLoginService->authenticateAdminApp(
$credentials['email'],
$credentials['password'],
$request->ip(),
$request->userAgent(),
);
$expirationMinutes = (int) config('sanctum.expiration');
$token = $user->createToken(
'adminapp-token',
['adminapp'],
now()->addMinutes($expirationMinutes),
)->plainTextToken;
return response()->json([
'code' => 'auth.login_success',
'message' => __('api.auth.login_success'),
'token' => $token,
'token_type' => 'Bearer',
'user' => UserResource::make($user),
]);
}
}

View File

@@ -2,22 +2,30 @@
namespace App\Domains\Auth\Models;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Role;
use App\Domains\Tenant\Models\Tenant;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id'])]
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
protected $attributes = [
'rol_codigo' => RoleCode::User->value,
];
protected static function newFactory(): UserFactory
{
return UserFactory::new();
@@ -35,6 +43,22 @@ class User extends Authenticatable
return $this->hasMany(LoginAttempt::class);
}
/**
* @return BelongsTo<Role, $this>
*/
public function role(): BelongsTo
{
return $this->belongsTo(Role::class, 'rol_codigo', 'codigo');
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return array<string, string>
*/

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class AdminAppLoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$email = $this->input('email');
if (is_string($email)) {
$this->merge([
'email' => Str::lower(trim($email)),
]);
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255'],
'password' => ['required', 'string'],
];
}
}

View File

@@ -2,11 +2,12 @@
namespace App\Domains\Auth\Resources;
use App\Domains\Auth\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Auth\Models\User
* @mixin User
*/
class UserResource extends JsonResource
{
@@ -21,6 +22,8 @@ class UserResource extends JsonResource
'email' => $this->email,
'dni' => $this->dni,
'telefono' => $this->telefono,
'rol_codigo' => $this->rol_codigo,
'tenant_codigo' => $this->tenant_codigo,
];
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Services;
use App\Domains\Auth\Exceptions\AccountLockedException;
use App\Domains\Auth\Models\LoginAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
@@ -22,6 +23,48 @@ class PasswordLoginService
string $tenantCode,
?string $ipAddress,
?string $userAgent,
): User {
return $this->authenticateUser(
$email,
$password,
$tenantCode,
$ipAddress,
$userAgent,
);
}
/**
* Authenticate a tenant-bound AdminApp user without requiring the caller
* to know their tenant code beforehand.
*
* @throws AccountLockedException
* @throws ValidationException
*/
public function authenticateAdminApp(
string $email,
string $password,
?string $ipAddress,
?string $userAgent,
): User {
return $this->authenticateUser(
$email,
$password,
null,
$ipAddress,
$userAgent,
RoleCode::AdminApp,
true,
);
}
private function authenticateUser(
string $email,
string $password,
?string $tenantCode,
?string $ipAddress,
?string $userAgent,
RoleCode $requiredRole = RoleCode::User,
bool $requiresTenant = false,
): User {
$normalizedEmail = mb_strtolower(trim($email));
$now = CarbonImmutable::now();
@@ -34,17 +77,25 @@ class PasswordLoginService
$ipAddress,
$userAgent,
$now,
$requiredRole,
$requiresTenant,
): array {
$user = User::query()
->where('email', $normalizedEmail)
->where('rol_codigo', $requiredRole->value)
->when(
$requiresTenant,
fn ($query) => $query->whereNotNull('tenant_codigo'),
)
->lockForUpdate()
->first();
$attemptTenantCode = $tenantCode ?? $user?->tenant_codigo;
if ($user?->locked_until?->isFuture()) {
$this->recordAttempt(
$user,
$normalizedEmail,
$tenantCode,
$attemptTenantCode,
LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
$ipAddress,
$userAgent,
@@ -76,7 +127,7 @@ class PasswordLoginService
$this->recordAttempt(
$user,
$normalizedEmail,
$tenantCode,
$attemptTenantCode,
$outcome,
$ipAddress,
$userAgent,
@@ -100,7 +151,7 @@ class PasswordLoginService
$this->recordAttempt(
$user,
$normalizedEmail,
$tenantCode,
$attemptTenantCode,
LoginAttempt::OUTCOME_SUCCESS,
$ipAddress,
$userAgent,
@@ -150,7 +201,7 @@ class PasswordLoginService
private function recordAttempt(
?User $user,
string $normalizedEmail,
string $tenantCode,
?string $tenantCode,
string $outcome,
?string $ipAddress,
?string $userAgent,

View File

@@ -0,0 +1,8 @@
<?php
use App\Domains\Auth\Controllers\AdminAppLoginController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp')->group(function (): void {
Route::post('login', AdminAppLoginController::class)->middleware('throttle:login');
});

View File

@@ -23,3 +23,5 @@ Route::post('/auth/google/exchange', GoogleTokenExchangeController::class);
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
Route::middleware('auth:sanctum')->get('/me', MeController::class);
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
require __DIR__.'/adminapp.php';

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Domains\Authorization\Enums;
enum RoleCode: string
{
case Admin = 'admin';
case AdminApp = 'adminapp';
case User = 'user';
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Authorization\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Permission extends Model
{
protected $table = 'permisos';
protected $fillable = [
'codigo',
'nombre',
'descripcion',
];
/**
* @return BelongsToMany<Role, $this>
*/
public function roles(): BelongsToMany
{
return $this->belongsToMany(
Role::class,
'roles_permisos',
'codigo_permiso',
'rol_codigo',
'codigo',
'codigo'
)->withTimestamps();
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Domains\Authorization\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Models\MenuRole;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Role extends Model
{
protected $table = 'roles';
protected $fillable = [
'codigo',
'nombre',
'descripcion',
];
/**
* @return BelongsToMany<Permission, $this>
*/
public function permissions(): BelongsToMany
{
return $this->belongsToMany(
Permission::class,
'roles_permisos',
'rol_codigo',
'codigo_permiso',
'codigo',
'codigo'
)->withTimestamps();
}
/**
* @return HasMany<User, $this>
*/
public function users(): HasMany
{
return $this->hasMany(User::class, 'rol_codigo', 'codigo');
}
/**
* @return BelongsToMany<Menu, $this>
*/
public function menus(): BelongsToMany
{
return $this->belongsToMany(
Menu::class,
'roles_menues',
'rol_codigo',
'menu_codigo',
'codigo',
'code'
)->using(MenuRole::class);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Authorization\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RolePermission extends Model
{
protected $table = 'roles_permisos';
protected $fillable = [
'codigo_permiso',
'rol_codigo',
];
/**
* @return BelongsTo<Permission, $this>
*/
public function permission(): BelongsTo
{
return $this->belongsTo(Permission::class, 'codigo_permiso', 'codigo');
}
/**
* @return BelongsTo<Role, $this>
*/
public function role(): BelongsTo
{
return $this->belongsTo(Role::class, 'rol_codigo', 'codigo');
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Menu\Models;
use App\Domains\Authorization\Models\Role;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -78,4 +79,16 @@ class Menu extends Model
->withPivot('static_content')
->withTimestamps();
}
public function roles(): BelongsToMany
{
return $this->belongsToMany(
Role::class,
'roles_menues',
'menu_codigo',
'rol_codigo',
'code',
'codigo'
)->using(MenuRole::class);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Menu\Models;
use App\Domains\Authorization\Models\Role;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Pivot;
class MenuRole extends Pivot
{
protected $table = 'roles_menues';
public $incrementing = true;
public $timestamps = false;
protected $fillable = [
'rol_codigo',
'menu_codigo',
];
public function role(): BelongsTo
{
return $this->belongsTo(Role::class, 'rol_codigo', 'codigo');
}
public function menu(): BelongsTo
{
return $this->belongsTo(Menu::class, 'menu_codigo', 'code');
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Domains\Tenant\Controllers\AdminApp;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Resources\TenantResource;
use App\Domains\Tenant\Services\TenantInformationService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class BootstrapTenantController extends Controller
{
public function __construct(
protected TenantInformationService $tenantInformationService
) {}
public function __invoke(Request $request): TenantResource
{
/** @var User $user */
$user = $request->user();
$tenant = $user->tenant()->firstOrFail();
return TenantResource::make(
$this->tenantInformationService->load($tenant, [
'menues' => fn ($query) => $query
->where('code', 'like', 'admin.%')
->whereHas(
'roles',
fn ($query) => $query->where('codigo', $user->rol_codigo)
),
])
);
}
}

View File

@@ -0,0 +1,79 @@
<?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())
);
}
private function loadTenant(User $user): Tenant
{
$tenant = $user->tenant()->firstOrFail();
return $this->tenantInformationService->load($tenant, [
'websiteType.extras',
]);
}
}

View File

@@ -2,30 +2,37 @@
namespace App\Domains\Tenant\Controllers;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Requests\BootstrapTenantRequest;
use App\Domains\Tenant\Resources\TenantResource;
use App\Domains\Tenant\Services\TenantInformationService;
use App\Http\Controllers\Controller;
class BootstrapTenantController extends Controller
{
public function __construct(
protected TenantInformationService $tenantInformationService
) {}
public function __invoke(BootstrapTenantRequest $request): TenantResource
{
/** @var string $dominio */
$dominio = $request->validated('dominio');
return TenantResource::make(
Tenant::query()
->with([
'headerLogo',
'footerLogo',
'mainCarouselImages',
'menues',
'socialMedia',
$this->tenantInformationService->load(
Tenant::query()
->where('dominio', $dominio)
->firstOrFail(),
[
'menues' => fn ($query) => $query->whereHas(
'roles',
fn ($query) => $query->where('codigo', RoleCode::User->value)
),
'categories' => fn ($query) => $query->orderBy('nombre'),
])
->where('dominio', $dominio)
->firstOrFail()
]
)
);
}
}

View File

@@ -6,6 +6,7 @@ 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;
@@ -13,16 +14,20 @@ use Illuminate\Http\Response;
class TenantController extends Controller
{
public function __construct(protected TenantService $tenantService) {}
public function __construct(
protected TenantService $tenantService,
protected TenantInformationService $tenantInformationService,
) {}
public function index(): JsonResponse
{
return TenantResource::collection(
Tenant::query()
->with(['headerLogo', 'footerLogo', 'mainCarouselImages', 'socialMedia'])
->latest()
->paginateFromRequest()
)->response();
$tenants = Tenant::query()
->latest()
->paginateFromRequest();
$this->tenantInformationService->loadMany($tenants->getCollection());
return TenantResource::collection($tenants)->response();
}
public function store(StoreTenantRequest $request): JsonResponse
@@ -30,14 +35,14 @@ class TenantController extends Controller
$tenant = $this->tenantService->create($request->validated());
return TenantResource::make(
$tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages', 'socialMedia'])
$this->tenantInformationService->load($tenant)
)->response()->setStatusCode(201);
}
public function show(Tenant $tenant): TenantResource
{
return TenantResource::make(
$tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages', 'socialMedia'])
$this->tenantInformationService->load($tenant)
);
}
@@ -46,7 +51,7 @@ class TenantController extends Controller
$tenant = $this->tenantService->update($tenant, $request->validated());
return TenantResource::make(
$tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages', 'socialMedia'])
$this->tenantInformationService->load($tenant)
);
}

View File

@@ -28,8 +28,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'footer_bg_color',
'header_logo_id',
'footer_logo_id',
'hero_config',
'event_config',
'website_type_code',
'search_product_layout',
'search_group_layout',
'search_items_per_page',
@@ -57,8 +56,6 @@ class Tenant extends Model
protected function casts(): array
{
return [
'hero_config' => 'array',
'event_config' => 'array',
'search_product_layout' => ProductLayout::class,
'search_group_layout' => GroupLayout::class,
'search_items_per_page' => 'integer',
@@ -82,27 +79,11 @@ class Tenant extends Model
}
/**
* @return BelongsTo<Attachment, $this>
* @return BelongsTo<WebsiteType, $this>
*/
public function heroBgImage(): BelongsTo
public function websiteType(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'hero_bg_image_id');
}
/**
* @return BelongsToMany<Attachment, $this>
*/
public function mainCarouselImages(): BelongsToMany
{
return $this->belongsToMany(
Attachment::class,
'tenant_main_carousel_images',
'tenant_id',
'attachment_id'
)
->withPivot('orden')
->withTimestamps()
->orderByPivot('orden');
return $this->belongsTo(WebsiteType::class, 'website_type_code', 'codigo');
}
public function catalogItems(): HasMany
@@ -136,6 +117,14 @@ class Tenant extends Model
->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(

View File

@@ -0,0 +1,66 @@
<?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';
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<WebsiteTypeExtra, $this>
*/
public function websiteTypeExtra(): BelongsTo
{
return $this->belongsTo(WebsiteTypeExtra::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,35 @@
<?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',
])]
class WebsiteType extends Model
{
use HasFactory;
protected $table = 'website_type';
/**
* @return HasMany<WebsiteTypeExtra, $this>
*/
public function extras(): HasMany
{
return $this->hasMany(WebsiteTypeExtra::class, 'website_type_code', 'codigo');
}
/**
* @return HasMany<Tenant, $this>
*/
public function tenants(): HasMany
{
return $this->hasMany(Tenant::class, '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([
'website_type_code',
'codigo',
'nombre',
'descripcion',
'is_required',
'config_schema',
])]
class WebsiteTypeExtra extends Model
{
use HasFactory;
protected $table = 'website_type_extras';
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_required' => 'boolean',
'config_schema' => 'array',
];
}
/**
* @return BelongsTo<WebsiteType, $this>
*/
public function websiteType(): BelongsTo
{
return $this->belongsTo(WebsiteType::class, '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,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

@@ -5,6 +5,7 @@ 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\Services\WebsiteExtraService;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
@@ -39,7 +40,7 @@ class StoreTenantRequest extends FormRequest
{
$logoRule = ['required', new ImageOrBase64Rule];
return [
return array_merge([
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
'nombre' => ['required', 'string', 'max:255'],
'dominio' => [
@@ -62,9 +63,6 @@ class StoreTenantRequest extends FormRequest
'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,
'hero_bg_image' => ['nullable', new ImageOrBase64Rule],
'main_carousel_images' => ['sometimes', 'array'],
'main_carousel_images.*' => ['required', 'distinct', new ImageOrBase64Rule],
'social_media' => ['sometimes', 'array'],
'social_media.*.code' => [
'required',
@@ -74,19 +72,15 @@ class StoreTenantRequest extends FormRequest
],
'social_media.*.url' => ['required', 'url', 'max:2048'],
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
'hero_config' => ['nullable', 'array'],
'hero_config.title_html' => ['nullable', 'string'],
'hero_config.description_html' => ['nullable', 'string'],
'hero_config.button_text' => ['nullable', 'string'],
'hero_config.button_href' => ['nullable', 'string'],
'event_config' => ['nullable', 'array'],
'event_config.title' => ['nullable', 'string'],
'event_config.location' => ['nullable', 'string'],
'event_config.dates' => ['nullable', 'array'],
'event_config.dates.*' => ['required', 'string'],
'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'],
];
'website_type_code' => [
'required_with:extras',
'sometimes',
'string',
Rule::exists('website_type', 'codigo'),
],
], app(WebsiteExtraService::class)->requestRules($this->input('website_type_code')));
}
}

View File

@@ -73,9 +73,6 @@ class UpdateTenantRequest extends FormRequest
'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,
'hero_bg_image' => ['nullable', new ImageOrBase64Rule],
'main_carousel_images' => ['sometimes', 'array'],
'main_carousel_images.*' => ['required', 'distinct', new ImageOrBase64Rule],
'social_media' => ['sometimes', 'array'],
'social_media.*.code' => [
'required',
@@ -85,16 +82,6 @@ class UpdateTenantRequest extends FormRequest
],
'social_media.*.url' => ['required', 'url', 'max:2048'],
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
'hero_config' => ['nullable', 'array'],
'hero_config.title_html' => ['nullable', 'string'],
'hero_config.description_html' => ['nullable', 'string'],
'hero_config.button_text' => ['nullable', 'string'],
'hero_config.button_href' => ['nullable', 'string'],
'event_config' => ['nullable', 'array'],
'event_config.title' => ['nullable', 'string'],
'event_config.location' => ['nullable', 'string'],
'event_config.dates' => ['nullable', 'array'],
'event_config.dates.*' => ['required', 'string'],
'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'],

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Domains\Tenant\Resources\AdminApp;
use App\Domains\Attachable\Models\Attachment;
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->resolvedConfig(),
fn (Attachment $attachment): string => $attachment->getTemporaryUrl(1440)
),
];
}
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,71 @@
<?php
namespace App\Domains\Tenant\Resources\AdminApp;
use App\Domains\Attachable\Models\Attachment;
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 [
'website_type' => $this->websiteType ? [
'codigo' => $this->websiteType->codigo,
'nombre' => $this->websiteType->nombre,
] : null,
'definitions' => $this->websiteType?->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->formatConfig(
$extra->resolvedConfig(),
fn (Attachment $attachment): string => $attachment->getTemporaryUrl(1440)
),
]),
];
}
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

@@ -2,6 +2,7 @@
namespace App\Domains\Tenant\Resources;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\Category;
use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant;
@@ -19,12 +20,6 @@ class TenantResource extends JsonResource
*/
public function toArray(Request $request): array
{
$heroConfig = $this->hero_config;
if (is_array($heroConfig)) {
$heroConfig['background_image'] = $this->heroBgImage?->getTemporaryUrl(1440);
unset($heroConfig['background_image_id']);
}
return [
'id' => $this->id,
'codigo' => $this->codigo,
@@ -36,20 +31,30 @@ class TenantResource extends JsonResource
'success_color' => $this->success_color,
'header_bg_color' => $this->header_bg_color,
'footer_bg_color' => $this->footer_bg_color,
'website_type_code' => $this->website_type_code,
'website_type' => $this->whenLoaded(
'websiteType',
fn () => $this->websiteType ? [
'codigo' => $this->websiteType->codigo,
'nombre' => $this->websiteType->nombre,
] : null
),
'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),
'hero_config' => $heroConfig,
'event_config' => $this->event_config,
'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,
'main_carousel_images' => $this->whenLoaded(
'mainCarouselImages',
fn () => $this->mainCarouselImages
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values()
),
'social_media' => $this->whenLoaded(
'socialMedia',
fn () => $this->socialMedia
@@ -72,6 +77,23 @@ class TenantResource extends JsonResource
];
}
private function formatExtraConfig(mixed $value): mixed
{
if ($value instanceof Attachment) {
return $value->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>>

View File

@@ -0,0 +1,177 @@
<?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',
'socialMedia',
'websiteType',
'websiteExtras.websiteTypeExtra',
];
/**
* @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)
->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

@@ -2,17 +2,18 @@
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\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class TenantService
{
public function __construct(protected AttachmentService $attachmentService) {}
public function __construct(
protected AttachmentService $attachmentService,
protected WebsiteExtraService $websiteExtraService,
) {}
/**
* Create a new tenant and store its logos.
@@ -24,16 +25,14 @@ class TenantService
return DB::transaction(function () use ($data): Tenant {
$headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null;
$heroBgImage = $data['hero_bg_image'] ?? null;
$mainCarouselImages = $data['main_carousel_images'] ?? [];
$socialMedia = $data['social_media'] ?? [];
$extras = $data['extras'] ?? [];
unset(
$data['header_logo'],
$data['footer_logo'],
$data['hero_bg_image'],
$data['main_carousel_images'],
$data['social_media']
$data['social_media'],
$data['extras'],
);
$headerAttachmentId = null;
@@ -61,22 +60,10 @@ class TenantService
$data['header_logo_id'] = $headerAttachmentId;
$data['footer_logo_id'] = $footerAttachmentId;
if ($heroBgImage) {
$attachment = Str::isUuid($heroBgImage)
? Attachment::query()->where('key', $heroBgImage)->first()
: $this->attachmentService->store($heroBgImage, 'tenants');
if ($attachment) {
$heroConfig = $data['hero_config'] ?? [];
$heroConfig['background_image_id'] = $attachment->id;
$data['hero_config'] = $heroConfig;
}
}
/** @var Tenant $tenant */
$tenant = Tenant::query()->create($data);
$this->syncMainCarouselImages($tenant, $mainCarouselImages);
$this->syncSocialMedia($tenant, $socialMedia);
$this->websiteExtraService->createForTenant($tenant, $extras);
return $tenant;
});
@@ -92,25 +79,17 @@ class TenantService
return DB::transaction(function () use ($tenant, $data): Tenant {
$hasHeaderLogoKey = array_key_exists('header_logo', $data);
$hasFooterLogoKey = array_key_exists('footer_logo', $data);
$hasHeroBgImageKey = array_key_exists('hero_bg_image', $data);
$hasMainCarouselImagesKey = array_key_exists('main_carousel_images', $data);
$hasSocialMediaKey = array_key_exists('social_media', $data);
$headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null;
$heroBgImage = $data['hero_bg_image'] ?? null;
$mainCarouselImages = $data['main_carousel_images'] ?? [];
$socialMedia = $data['social_media'] ?? [];
unset(
$data['header_logo'],
$data['footer_logo'],
$data['hero_bg_image'],
$data['main_carousel_images'],
$data['social_media']
);
$oldHeroBgId = $tenant->hero_bg_image_id;
$tenant->fill($data);
if ($hasHeaderLogoKey) {
@@ -145,34 +124,8 @@ class TenantService
}
}
$currentHeroConfig = $tenant->hero_config ?? [];
if ($hasHeroBgImageKey) {
if ($heroBgImage) {
$attachment = Str::isUuid($heroBgImage)
? Attachment::query()->where('key', $heroBgImage)->first()
: $this->attachmentService->store($heroBgImage, 'tenants');
if ($attachment) {
$currentHeroConfig['background_image_id'] = $attachment->id;
} else {
unset($currentHeroConfig['background_image_id']);
}
} else {
unset($currentHeroConfig['background_image_id']);
}
} else {
if ($oldHeroBgId) {
$currentHeroConfig['background_image_id'] = $oldHeroBgId;
}
}
$tenant->hero_config = empty($currentHeroConfig) ? null : $currentHeroConfig;
$tenant->save();
if ($hasMainCarouselImagesKey) {
$this->syncMainCarouselImages($tenant, $mainCarouselImages);
}
if ($hasSocialMediaKey) {
$this->syncSocialMedia($tenant, $socialMedia);
}
@@ -181,21 +134,6 @@ class TenantService
});
}
/**
* @param array<int, mixed> $images
*/
private function syncMainCarouselImages(Tenant $tenant, array $images): void
{
$attachments = [];
foreach (array_values($images) as $order => $image) {
$attachment = $this->resolveMainCarouselImage($image, $order);
$attachments[$attachment->id] = ['orden' => $order];
}
$tenant->mainCarouselImages()->sync($attachments);
}
/**
* @param array<int, array{code: string, url: string, orden?: int}> $socialMedia
*/
@@ -213,26 +151,4 @@ class TenantService
$tenant->socialMedia()->sync($associations);
$tenant->unsetRelation('socialMedia');
}
private function resolveMainCarouselImage(mixed $image, int $order): Attachment
{
if (is_string($image) && Str::isUuid($image)) {
$attachment = Attachment::query()
->where('key', $image)
->where('type', AttachmentType::Image->value)
->first();
if ($attachment === null) {
throw ValidationException::withMessages([
"main_carousel_images.{$order}" => [
__('api.tenant.invalid_carousel_image'),
],
]);
}
return $attachment;
}
return $this->attachmentService->store($image, 'tenants/main-carousel');
}
}

View File

@@ -0,0 +1,359 @@
<?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\ImageOrBase64Rule;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteExtra;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Tenant\Models\WebsiteTypeExtra;
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 (WebsiteTypeExtra $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->website_type_code)
->keyBy('codigo');
foreach ($extras as $name => $config) {
/** @var WebsiteTypeExtra|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()
->firstOrFail();
$websiteExtra->update([
'is_enabled' => ! $websiteExtra->is_enabled,
]);
return $websiteExtra;
});
}
public function definitionForTenant(Tenant $tenant, string $extraCode): WebsiteTypeExtra
{
return WebsiteTypeExtra::query()
->where('website_type_code', $tenant->website_type_code)
->where('codigo', $extraCode)
->firstOrFail();
}
/**
* @return Collection<int, WebsiteTypeExtra>
*/
private function definitionsFor(string $websiteTypeCode): Collection
{
$websiteType = WebsiteType::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 => $rule === 'image_or_base64'
? new ImageOrBase64Rule
: $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,
WebsiteTypeExtra $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,
WebsiteTypeExtra $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')
);
}
if (is_string($value) && Str::isUuid($value)) {
$attachment = Attachment::query()->where('key', $value)->first();
if (! $attachment) {
throw ValidationException::withMessages([
$this->configAttribute($requestRoot, $path) => [
'The selected attachment does not exist.',
],
]);
}
} else {
$attachment = $this->attachmentService->store(
$value,
"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;
}
}

View File

@@ -0,0 +1,15 @@
<?php
use App\Domains\Tenant\Controllers\AdminApp\BootstrapTenantController;
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('bootstrap', BootstrapTenantController::class);
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

@@ -8,3 +8,5 @@ Route::get('tenants/bootstrap/{dominio}', BootstrapTenantController::class)
->where('dominio', '.*');
Route::apiResource('tenants', TenantController::class);
require __DIR__.'/adminapp.php';

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use App\Domains\Authorization\Enums\RoleCode;
use Closure;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureAdminAppTenant
{
/**
* Ensure the authenticated user is an AdminApp user bound to a tenant.
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (
! $user
|| $user->rol_codigo !== RoleCode::AdminApp->value
|| ! $user->tenant_codigo
) {
throw new AuthorizationException;
}
return $next($request);
}
}

View File

@@ -2,6 +2,7 @@
use App\Domains\Auth\Exceptions\AccountLockedException;
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
use App\Http\Middleware\EnsureAdminAppTenant;
use App\Http\Middleware\SetApiLocale;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
@@ -21,6 +22,9 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'adminapp.tenant' => EnsureAdminAppTenant::class,
]);
$middleware->encryptCookies(except: [
'guest_token',
]);

View File

@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('permisos', function (Blueprint $table): void {
$table->id();
$table->string('codigo')->unique();
$table->string('nombre');
$table->text('descripcion')->nullable();
$table->timestamps();
});
Schema::create('roles', function (Blueprint $table): void {
$table->id();
$table->string('codigo')->unique();
$table->string('nombre');
$table->text('descripcion')->nullable();
$table->timestamps();
});
Schema::create('roles_permisos', function (Blueprint $table): void {
$table->id();
$table->string('codigo_permiso');
$table->string('rol_codigo');
$table->timestamps();
$table->foreign('codigo_permiso')
->references('codigo')
->on('permisos')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->foreign('rol_codigo')
->references('codigo')
->on('roles')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->unique(['rol_codigo', 'codigo_permiso']);
});
}
public function down(): void
{
Schema::dropIfExists('roles_permisos');
Schema::dropIfExists('roles');
Schema::dropIfExists('permisos');
}
};

View File

@@ -0,0 +1,57 @@
<?php
use App\Domains\Authorization\Enums\RoleCode;
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
{
$now = now();
DB::table('roles')->insertOrIgnore([
[
'codigo' => RoleCode::Admin->value,
'nombre' => 'Administrador general',
'descripcion' => 'Administra la plataforma y todos sus tenants.',
'created_at' => $now,
'updated_at' => $now,
],
[
'codigo' => RoleCode::User->value,
'nombre' => 'Usuario',
'descripcion' => 'Cliente final limitado a sus propios datos y operaciones.',
'created_at' => $now,
'updated_at' => $now,
],
]);
Schema::table('users', function (Blueprint $table): void {
$table->string('rol_codigo')->default(RoleCode::User->value)->after('id');
$table->string('tenant_codigo')->nullable()->after('rol_codigo');
$table->foreign('rol_codigo')
->references('codigo')
->on('roles')
->cascadeOnUpdate()
->restrictOnDelete();
$table->foreign('tenant_codigo')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->restrictOnDelete();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropForeign(['tenant_codigo']);
$table->dropForeign(['rol_codigo']);
$table->dropColumn(['tenant_codigo', 'rol_codigo']);
});
}
};

View File

@@ -0,0 +1,73 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('website_type', function (Blueprint $table): void {
$table->id();
$table->string('codigo')->unique();
$table->string('nombre');
$table->timestamps();
});
Schema::table('tenants', function (Blueprint $table): void {
$table->string('website_type_code')->nullable();
$table->foreign('website_type_code')
->references('codigo')
->on('website_type')
->cascadeOnUpdate()
->nullOnDelete();
});
Schema::create('website_type_extras', function (Blueprint $table): void {
$table->id();
$table->string('website_type_code');
$table->foreign('website_type_code')
->references('codigo')
->on('website_type')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->string('nombre');
$table->text('descripcion');
$table->boolean('is_required')->default(false);
$table->json('config_schema');
$table->timestamps();
});
Schema::create('websites_extras', function (Blueprint $table): void {
$table->id();
$table->string('website_code');
$table->foreignId('website_type_extra_id')
->constrained('website_type_extras')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->json('config');
$table->timestamps();
$table->foreign('website_code')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('websites_extras');
Schema::dropIfExists('website_type_extras');
Schema::table('tenants', function (Blueprint $table): void {
$table->dropForeign(['website_type_code']);
$table->dropColumn('website_type_code');
});
Schema::dropIfExists('website_type');
}
};

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::dropIfExists('tenant_main_carousel_images');
Schema::table('tenants', function (Blueprint $table): void {
$table->dropColumn('hero_bg_image_id');
$table->dropColumn(['hero_config', 'event_config']);
});
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table): void {
$table->json('hero_config')->nullable();
$table->json('event_config')->nullable();
$table->unsignedBigInteger('hero_bg_image_id')
->virtualAs('hero_config->>"$.background_image_id"')
->nullable();
});
Schema::create('tenant_main_carousel_images', function (Blueprint $table): void {
$table->id();
$table->foreignId('tenant_id')
->constrained('tenants')
->cascadeOnDelete();
$table->foreignId('attachment_id')
->constrained('attachments')
->cascadeOnDelete();
$table->unsignedInteger('orden')->default(0);
$table->timestamps();
$table->unique(['tenant_id', 'attachment_id']);
$table->index(['tenant_id', 'orden']);
});
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('roles_menues', function (Blueprint $table): void {
$table->id();
$table->string('rol_codigo');
$table->string('menu_codigo');
$table->foreign('rol_codigo')
->references('codigo')
->on('roles')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->foreign('menu_codigo')
->references('code')
->on('menues')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->unique(['rol_codigo', 'menu_codigo']);
});
}
public function down(): void
{
Schema::dropIfExists('roles_menues');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use App\Domains\Authorization\Enums\RoleCode;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
private const REMOVED_ROLE_CODE = 'tenant_admin';
public function up(): void
{
DB::table('users')
->where('rol_codigo', self::REMOVED_ROLE_CODE)
->update([
'rol_codigo' => RoleCode::User->value,
'tenant_codigo' => null,
]);
DB::table('roles')
->where('codigo', self::REMOVED_ROLE_CODE)
->delete();
}
public function down(): void
{
DB::table('roles')->insertOrIgnore([
'codigo' => self::REMOVED_ROLE_CODE,
'nombre' => 'Administrador del tenant',
'descripcion' => 'Administra la operación de su propio tenant.',
'created_at' => now(),
'updated_at' => now(),
]);
}
};

View File

@@ -0,0 +1,21 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('menues_roles') && ! Schema::hasTable('roles_menues')) {
Schema::rename('menues_roles', 'roles_menues');
}
}
public function down(): void
{
if (Schema::hasTable('roles_menues') && ! Schema::hasTable('menues_roles')) {
Schema::rename('roles_menues', 'menues_roles');
}
}
};

View File

@@ -0,0 +1,52 @@
<?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
{
Schema::table('website_type_extras', function (Blueprint $table): void {
$table->string('codigo')->nullable()->after('website_type_code');
});
Schema::table('websites_extras', function (Blueprint $table): void {
$table->unique(
['website_code', 'website_type_extra_id'],
'websites_extras_website_definition_unique'
);
});
DB::table('website_type_extras')
->select(['id', 'nombre'])
->orderBy('id')
->each(function (object $extra): void {
DB::table('website_type_extras')
->where('id', $extra->id)
->update(['codigo' => $extra->nombre]);
});
Schema::table('website_type_extras', function (Blueprint $table): void {
$table->string('codigo')->nullable(false)->change();
$table->unique(
['website_type_code', 'codigo'],
'website_type_extras_type_code_unique'
);
});
}
public function down(): void
{
Schema::table('websites_extras', function (Blueprint $table): void {
$table->dropUnique('websites_extras_website_definition_unique');
});
Schema::table('website_type_extras', function (Blueprint $table): void {
$table->dropUnique('website_type_extras_type_code_unique');
$table->dropColumn('codigo');
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('website_type_extras')
->select(['id', 'config_schema'])
->orderBy('id')
->each(function (object $definition): void {
$schema = json_decode($definition->config_schema, true);
if (! is_array($schema) || ! array_key_exists('database_rules', $schema)) {
return;
}
unset($schema['database_rules']);
DB::table('website_type_extras')
->where('id', $definition->id)
->update(['config_schema' => json_encode($schema)]);
});
}
public function down(): void
{
// Removed rules cannot be reconstructed generically.
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('websites_extras', function (Blueprint $table): void {
$table->boolean('is_enabled')->nullable()->after('config');
});
}
public function down(): void
{
Schema::table('websites_extras', function (Blueprint $table): void {
$table->dropColumn('is_enabled');
});
}
};

View File

@@ -0,0 +1,152 @@
<?php
namespace Database\Seeders;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Permission;
use App\Domains\Authorization\Models\Role;
use Illuminate\Database\Seeder;
class AuthorizationSeeder extends Seeder
{
/**
* @var array<string, array{nombre: string, descripcion: string}>
*/
private const PERMISSIONS = [
'tenant.ver' => [
'nombre' => 'Ver configuración del tenant',
'descripcion' => 'Permite consultar la configuración general de un tenant.',
],
'tenant.editar' => [
'nombre' => 'Editar configuración del tenant',
'descripcion' => 'Permite modificar los datos generales, dominio, colores y recursos visuales del tenant.',
],
'usuarios.ver' => [
'nombre' => 'Ver usuarios',
'descripcion' => 'Permite consultar los usuarios asociados al tenant.',
],
'usuarios.gestionar' => [
'nombre' => 'Gestionar usuarios',
'descripcion' => 'Permite invitar, editar, desactivar y asignar roles permitidos a los usuarios del tenant.',
],
'roles.ver' => [
'nombre' => 'Ver roles',
'descripcion' => 'Permite consultar los roles y sus permisos.',
],
'roles.gestionar' => [
'nombre' => 'Gestionar roles',
'descripcion' => 'Permite crear, modificar y asignar permisos a roles.',
],
'catalogo.ver' => [
'nombre' => 'Ver catálogo',
'descripcion' => 'Permite consultar administrativamente productos, variantes, categorías, marcas y bundles.',
],
'catalogo.gestionar' => [
'nombre' => 'Gestionar catálogo',
'descripcion' => 'Permite crear, modificar, archivar y eliminar productos, variantes, categorías, marcas y bundles.',
],
'inventario.ver' => [
'nombre' => 'Ver inventario',
'descripcion' => 'Permite consultar el stock y la información de inventario.',
],
'inventario.ajustar' => [
'nombre' => 'Ajustar inventario',
'descripcion' => 'Permite modificar el stock disponible.',
],
'pedidos.ver' => [
'nombre' => 'Ver pedidos',
'descripcion' => 'Permite consultar los pedidos del tenant.',
],
'pedidos.gestionar' => [
'nombre' => 'Gestionar pedidos',
'descripcion' => 'Permite modificar estados y datos operativos de los pedidos.',
],
'pedidos.cancelar' => [
'nombre' => 'Cancelar pedidos',
'descripcion' => 'Permite cancelar pedidos y liberar sus reservas de inventario.',
],
'pagos.ver' => [
'nombre' => 'Ver pagos',
'descripcion' => 'Permite consultar el estado y la información de los pagos.',
],
'pagos.gestionar' => [
'nombre' => 'Gestionar pagos',
'descripcion' => 'Permite confirmar, rechazar o devolver pagos.',
],
'tickets.ver' => [
'nombre' => 'Ver tickets',
'descripcion' => 'Permite consultar los tickets emitidos.',
],
'tickets.gestionar' => [
'nombre' => 'Gestionar tickets',
'descripcion' => 'Permite emitir, invalidar o regenerar tickets.',
],
'contenido.gestionar' => [
'nombre' => 'Gestionar contenido',
'descripcion' => 'Permite administrar menús, carruseles, destacados y redes sociales.',
],
'integraciones.ver' => [
'nombre' => 'Ver integraciones',
'descripcion' => 'Permite consultar la configuración y el estado de las integraciones.',
],
'integraciones.gestionar' => [
'nombre' => 'Gestionar integraciones',
'descripcion' => 'Permite configurar credenciales y proveedores de integraciones.',
],
'reportes.ver' => [
'nombre' => 'Ver reportes',
'descripcion' => 'Permite consultar reportes de ventas y métricas.',
],
'reportes.exportar' => [
'nombre' => 'Exportar reportes',
'descripcion' => 'Permite descargar reportes e información comercial.',
],
];
/**
* @var array<string, array{nombre: string, descripcion: string, permisos: list<string>}>
*/
private const ROLES = [
RoleCode::Admin->value => [
'nombre' => 'Administrador general',
'descripcion' => 'Administra la plataforma y todos sus tenants.',
'permisos' => [],
],
RoleCode::AdminApp->value => [
'nombre' => 'Administrador de la aplicación',
'descripcion' => 'Accede a los menús administrativos de la aplicación.',
'permisos' => [],
],
RoleCode::User->value => [
'nombre' => 'Usuario',
'descripcion' => 'Cliente final limitado a sus propios datos y operaciones.',
'permisos' => [],
],
];
public function run(): void
{
foreach (self::PERMISSIONS as $code => $attributes) {
Permission::query()->updateOrCreate(
['codigo' => $code],
$attributes
);
}
foreach (self::ROLES as $code => $attributes) {
$role = Role::query()->updateOrCreate(
['codigo' => $code],
[
'nombre' => $attributes['nombre'],
'descripcion' => $attributes['descripcion'],
]
);
$permissionCodes = $code === RoleCode::Admin->value
? array_keys(self::PERMISSIONS)
: $attributes['permisos'];
$role->permissions()->sync($permissionCodes);
}
}
}

View File

@@ -23,6 +23,8 @@ class DatabaseSeeder extends Seeder
]);
$this->call([
WebsiteTypeSeeder::class,
AuthorizationSeeder::class,
SocialMediaSeeder::class,
TenantSeeder::class,
AttributeSeeder::class,

View File

@@ -2,6 +2,8 @@
namespace Database\Seeders;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Role;
use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Seeder;
@@ -21,6 +23,16 @@ class MenuSeeder extends Seeder
'route' => '/product/:id',
],
['code' => 'checkout', 'label' => 'Finalizar compra', 'route' => '/checkout'],
['code' => 'admin.event', 'label' => 'Eventos', 'route' => '/admin/event'],
['code' => 'admin.catalog', 'label' => 'Catálogo', 'route' => '/admin/catalog'],
['code' => 'admin.combos', 'label' => 'Combos', 'route' => '/admin/combos'],
[
'code' => 'admin.categories',
'label' => 'Categorías',
'route' => '/admin/categories',
],
['code' => 'admin.ventas', 'label' => 'Ventas', 'route' => '/admin/ventas'],
['code' => 'admin.staff', 'label' => 'Staff', 'route' => '/admin/staff'],
[
'code' => 'account',
'label' => 'Mi cuenta',
@@ -123,6 +135,19 @@ class MenuSeeder extends Seeder
->whereIn('code', ['profile', 'purchases', 'tickets'])
->delete();
$allRoleMenuCodes = Menu::query()->pluck('code');
$userMenuCodes = Menu::query()
->where('code', 'not like', 'admin.%')
->pluck('code');
Role::query()
->whereIn('codigo', [RoleCode::Admin->value, RoleCode::AdminApp->value])
->each(fn (Role $role) => $role->menus()->sync($allRoleMenuCodes));
Role::query()
->where('codigo', RoleCode::User->value)
->each(fn (Role $role) => $role->menus()->sync($userMenuCodes));
$tenants = Tenant::all();
$allMenus = Menu::pluck('code')->toArray();

View File

@@ -2,23 +2,17 @@
namespace Database\Seeders;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Services\TenantService;
use Illuminate\Database\Seeder;
use Illuminate\Http\UploadedFile;
use RuntimeException;
use Throwable;
class TenantSeeder extends Seeder
{
private const SONDER_MAIN_CAROUSEL_IMAGES = [
'01-urban-team.png',
'02-running-shoes.png',
'03-streetwear.png',
'04-football-training.png',
'05-activewear-essentials.png',
'06-city-runners.png',
];
private const SOCIAL_MEDIA = [
[
'code' => 'instagram',
@@ -44,88 +38,13 @@ class TenantSeeder extends Seeder
public function __construct(protected TenantService $tenantService) {}
/**
* Run the database seeds.
*/
public function run(): void
{
// Check if tenant 'sonder' already exists and delete it to prevent duplicates
$existing = Tenant::query()->where('codigo', 'sonder')->first();
if ($existing) {
$attachmentService = app(AttachmentService::class);
if ($existing->headerLogo) {
try {
$attachmentService->delete($existing->headerLogo);
} catch (\Throwable $e) {
// Ignore exception on cleanup
}
}
if ($existing->footerLogo && $existing->footer_logo_id !== $existing->header_logo_id) {
try {
$attachmentService->delete($existing->footerLogo);
} catch (\Throwable $e) {
// Ignore exception on cleanup
}
}
foreach ($existing->mainCarouselImages as $mainCarouselImage) {
try {
$attachmentService->delete($mainCarouselImage);
} catch (\Throwable $e) {
// Ignore exception on cleanup
}
}
$existing->delete();
}
$this->deleteTenant('sonder');
// Check if domain 'localhost' is already in use by another tenant and delete it
$existingDomain = Tenant::query()->where('dominio', 'localhost')->first();
if ($existingDomain) {
$existingDomain->delete();
}
$headerImagePath = public_path('images/sonder_header.png');
$footerImagePath = public_path('images/sonder_footer.png');
if (! file_exists($headerImagePath)) {
throw new \RuntimeException("Image not found at path: {$headerImagePath}");
}
if (! file_exists($footerImagePath)) {
throw new \RuntimeException("Image not found at path: {$footerImagePath}");
}
$headerLogo = new UploadedFile(
$headerImagePath,
'sonder_header.png',
'image/png',
null,
true
);
$footerLogo = new UploadedFile(
$footerImagePath,
'sonder_footer.png',
'image/png',
null,
true
);
$mainCarouselImages = [];
foreach (self::SONDER_MAIN_CAROUSEL_IMAGES as $filename) {
$imagePath = public_path("images/sonder-main-carousel/{$filename}");
if (! file_exists($imagePath)) {
throw new \RuntimeException("Image not found at path: {$imagePath}");
}
$mainCarouselImages[] = new UploadedFile(
$imagePath,
$filename,
'image/png',
null,
true
);
}
Tenant::query()
->where('dominio', 'localhost')
->delete();
$this->tenantService->create([
'codigo' => 'sonder',
@@ -137,82 +56,46 @@ class TenantSeeder extends Seeder
'success_color' => '#198754',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#313131',
'header_logo' => $headerLogo,
'footer_logo' => $footerLogo,
'main_carousel_images' => $mainCarouselImages,
'header_logo' => $this->uploadedImage('images/sonder_header.png', 'sonder_header.png'),
'footer_logo' => $this->uploadedImage('images/sonder_footer.png', 'sonder_footer.png'),
'social_media' => self::SOCIAL_MEDIA,
'website_type_code' => 'shopit',
'extras' => [
'carousel' => [
$this->uploadedImage(
'images/sonder-main-carousel/01-urban-team.png',
'01-urban-team.png',
),
$this->uploadedImage(
'images/sonder-main-carousel/02-running-shoes.png',
'02-running-shoes.png',
),
$this->uploadedImage(
'images/sonder-main-carousel/03-streetwear.png',
'03-streetwear.png',
),
$this->uploadedImage(
'images/sonder-main-carousel/04-football-training.png',
'04-football-training.png',
),
$this->uploadedImage(
'images/sonder-main-carousel/05-activewear-essentials.png',
'05-activewear-essentials.png',
),
$this->uploadedImage(
'images/sonder-main-carousel/06-city-runners.png',
'06-city-runners.png',
),
],
],
]);
// Check if tenant 'fiesta_futbol_infantil' already exists
$existingFiesta = Tenant::query()->where('codigo', 'fiesta_futbol_infantil')->first();
if ($existingFiesta) {
$attachmentService = app(AttachmentService::class);
if ($existingFiesta->headerLogo) {
try {
$attachmentService->delete($existingFiesta->headerLogo);
} catch (\Throwable $e) {
}
}
if ($existingFiesta->footerLogo && $existingFiesta->footer_logo_id !== $existingFiesta->header_logo_id) {
try {
$attachmentService->delete($existingFiesta->footerLogo);
} catch (\Throwable $e) {
}
}
if ($existingFiesta->heroBgImage) {
try {
$attachmentService->delete($existingFiesta->heroBgImage);
} catch (\Throwable $e) {
}
}
$existingFiesta->delete();
}
$this->deleteTenant('fiesta_futbol_infantil');
$fiestaDomain = 'fiesta-futbol-infantil.localhost';
$existingFiestaDomain = Tenant::query()->where('dominio', $fiestaDomain)->first();
if ($existingFiestaDomain) {
$existingFiestaDomain->delete();
}
$fiestaHeaderImagePath = public_path('images/futbol_infantil_header.png');
$fiestaFooterImagePath = public_path('images/futbol_infantil_footer.png');
$fiestaHeroBgImagePath = public_path('images/futbol_infantil_hero.jpg');
if (! file_exists($fiestaHeaderImagePath)) {
throw new \RuntimeException("Image not found at path: {$fiestaHeaderImagePath}");
}
if (! file_exists($fiestaFooterImagePath)) {
throw new \RuntimeException("Image not found at path: {$fiestaFooterImagePath}");
}
if (! file_exists($fiestaHeroBgImagePath)) {
throw new \RuntimeException("Image not found at path: {$fiestaHeroBgImagePath}");
}
$fiestaHeaderLogo = new UploadedFile(
$fiestaHeaderImagePath,
'futbol_infantil_header.png',
'image/png',
null,
true
);
$fiestaFooterLogo = new UploadedFile(
$fiestaFooterImagePath,
'futbol_infantil_footer.png',
'image/png',
null,
true
);
$fiestaHeroBgImage = new UploadedFile(
$fiestaHeroBgImagePath,
'futbol_infantil_hero.jpg',
'image/jpeg',
null,
true
);
Tenant::query()
->where('dominio', $fiestaDomain)
->delete();
$this->tenantService->create([
'codigo' => 'fiesta_futbol_infantil',
@@ -224,21 +107,110 @@ class TenantSeeder extends Seeder
'success_color' => '#198754',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#015327',
'header_logo' => $fiestaHeaderLogo,
'footer_logo' => $fiestaFooterLogo,
'hero_bg_image' => $fiestaHeroBgImage,
'hero_config' => [
'title_html' => '<strong>ASEGURÁ TU LUGAR</strong>',
'description_html' => '<strong>Comprá tu entrada oficial en segundos</strong> de forma 100% segura. Preparate para vivir la experiencia completa.',
'button_text' => 'Quiero mi entrada',
'button_href' => null,
],
'event_config' => [
'title' => 'FIESTA NACIONAL DEL FÚTBOL INFANTIL',
'location' => 'Sunchales, Santa Fe',
'dates' => ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'],
],
'header_logo' => $this->uploadedImage(
'images/futbol_infantil_header.png',
'futbol_infantil_header.png',
),
'footer_logo' => $this->uploadedImage(
'images/futbol_infantil_footer.png',
'futbol_infantil_footer.png',
),
'social_media' => self::SOCIAL_MEDIA,
'website_type_code' => 'onticket',
'extras' => [
'heroConfig' => [
'title_html' => '<h1>Fiesta Fútbol Infantil</h1>',
'description_html' => '<p>Viví una jornada inolvidable de fútbol infantil.</p>',
'button_text' => 'Comprar entradas',
'button_href' => '/tickets',
'background_image_id' => $this->uploadedImage(
'images/futbol_infantil_hero.jpg',
'futbol_infantil_hero.jpg',
),
],
'eventConfig' => [
'title' => 'Fiesta Fútbol Infantil',
'location' => 'Rosario, Santa Fe',
'dates_text' => '9, 10, 11 y 12 de Octubre 2026',
'dates' => [
'2026-12-05',
'2026-12-06',
],
],
],
]);
}
private function deleteTenant(string $codigo): void
{
$tenant = Tenant::query()
->where('codigo', $codigo)
->first();
if ($tenant === null) {
return;
}
$attachmentService = app(AttachmentService::class);
$extraAttachments = Attachment::query()
->where('path', 'like', 'tenants/%/extras/%')
->get()
->filter(
fn (Attachment $attachment): bool => str_starts_with(
$attachment->path,
"tenants/{$codigo}/extras/"
)
);
foreach ($extraAttachments as $attachment) {
try {
$attachmentService->delete($attachment);
} catch (Throwable) {
// Ignore cleanup errors while recreating demo data.
}
}
if ($tenant->headerLogo) {
try {
$attachmentService->delete($tenant->headerLogo);
} catch (Throwable) {
// Ignore cleanup errors while recreating demo data.
}
}
if ($tenant->footerLogo && $tenant->footer_logo_id !== $tenant->header_logo_id) {
try {
$attachmentService->delete($tenant->footerLogo);
} catch (Throwable) {
// Ignore cleanup errors while recreating demo data.
}
}
$tenant->delete();
}
private function uploadedImage(string $relativePath, string $filename): UploadedFile
{
$path = public_path($relativePath);
if (! file_exists($path)) {
throw new RuntimeException("Image not found at path: {$path}");
}
$mimeType = match (strtolower(pathinfo($filename, PATHINFO_EXTENSION))) {
'jpg', 'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'webp' => 'image/webp',
default => 'image/png',
};
return new UploadedFile(
$path,
$filename,
$mimeType,
null,
true,
);
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Database\Seeders;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Database\Seeder;
class WebsiteTypeSeeder extends Seeder
{
public function run(): void
{
$shopIt = WebsiteType::query()->updateOrCreate(
['codigo' => 'shopit'],
['nombre' => 'ShopIt'],
);
$shopIt->extras()->updateOrCreate(
['codigo' => 'carousel'],
[
'nombre' => 'Carrusel principal',
'descripcion' => 'Listado de attachments que se muestran en el carousel principal.',
'is_required' => false,
'config_schema' => [
'request_rules' => [
'$' => 'required|array|max:10',
'$.*' => 'required|image_or_base64|distinct',
],
'transforms' => [
'$.*' => [
'handler' => 'attachment',
'attachment_type' => 'image',
],
],
],
],
);
$onTicket = WebsiteType::query()->updateOrCreate(
['codigo' => 'onticket'],
['nombre' => 'OnTicket'],
);
$onTicket->extras()->updateOrCreate(
['codigo' => 'heroConfig'],
[
'nombre' => 'Configuración del hero',
'descripcion' => 'Configuracion del hero principal del evento.',
'is_required' => false,
'config_schema' => [
'request_rules' => [
'$' => 'required|array',
'title_html' => 'nullable|string',
'description_html' => 'nullable|string',
'button_text' => 'nullable|string',
'button_href' => 'nullable|string',
'background_image_id' => 'nullable|image_or_base64',
],
'transforms' => [
'background_image_id' => [
'handler' => 'attachment',
'attachment_type' => 'image',
],
],
],
],
);
$onTicket->extras()->updateOrCreate(
['codigo' => 'eventConfig'],
[
'nombre' => 'Información del evento',
'descripcion' => 'Informacion principal del evento.',
'is_required' => false,
'config_schema' => [
'request_rules' => [
'$' => 'required|array',
'title' => 'nullable|string',
'location' => 'nullable|string',
'dates_text' => 'nullable|string|max:255',
'dates' => 'nullable|array',
'dates.*' => 'required|date_format:Y-m-d|distinct',
],
'transforms' => [],
],
],
);
}
}

91
docs/authorization.md Normal file
View File

@@ -0,0 +1,91 @@
# Roles y permisos
El sistema utiliza control de acceso basado en roles. Los códigos son identificadores
estables para uso interno; los nombres y las descripciones pueden modificarse sin
afectar las reglas de autorización.
Los permisos administrativos deben evaluarse siempre dentro del tenant correspondiente.
El rol `admin` es la única excepción porque su alcance es toda la plataforma.
Cada registro de `users` contiene:
- `rol_codigo`: código obligatorio del rol. Su valor predeterminado es `user`.
- `tenant_codigo`: código nullable del tenant. Solamente se utiliza para usuarios con
rol `tenant_admin`; para `admin` y `user` debe permanecer en `null`.
## Roles
| Código | Nombre | Alcance |
| --- | --- | --- |
| `admin` | Administrador general | Administra la plataforma, todos los tenants, roles y permisos. Tiene todos los permisos. |
| `tenant_admin` | Administrador del tenant | Administra usuarios y la operación de su tenant. No puede gestionar la definición de roles. |
| `user` | Usuario | Cliente final. No tiene permisos administrativos y solamente puede operar sobre sus propios datos, carrito, pedidos y tickets. |
## Permisos
| Código | Nombre | Descripción |
| --- | --- | --- |
| `tenant.ver` | Ver configuración del tenant | Consultar la configuración general de un tenant. |
| `tenant.editar` | Editar configuración del tenant | Modificar datos generales, dominio, colores y recursos visuales. |
| `usuarios.ver` | Ver usuarios | Consultar los usuarios asociados al tenant. |
| `usuarios.gestionar` | Gestionar usuarios | Invitar, editar, desactivar y asignar roles permitidos a usuarios del tenant. |
| `roles.ver` | Ver roles | Consultar los roles y sus permisos. |
| `roles.gestionar` | Gestionar roles | Crear, modificar y asignar permisos a roles. |
| `catalogo.ver` | Ver catálogo | Consultar productos, variantes, categorías, marcas y bundles administrativamente. |
| `catalogo.gestionar` | Gestionar catálogo | Crear, modificar, archivar y eliminar productos, variantes, categorías, marcas y bundles. |
| `inventario.ver` | Ver inventario | Consultar stock e información de inventario. |
| `inventario.ajustar` | Ajustar inventario | Modificar el stock disponible. |
| `pedidos.ver` | Ver pedidos | Consultar los pedidos del tenant. |
| `pedidos.gestionar` | Gestionar pedidos | Modificar estados y datos operativos de pedidos. |
| `pedidos.cancelar` | Cancelar pedidos | Cancelar pedidos y liberar sus reservas de inventario. |
| `pagos.ver` | Ver pagos | Consultar el estado y la información de pagos. |
| `pagos.gestionar` | Gestionar pagos | Confirmar, rechazar o devolver pagos. |
| `tickets.ver` | Ver tickets | Consultar tickets emitidos. |
| `tickets.gestionar` | Gestionar tickets | Emitir, invalidar o regenerar tickets. |
| `contenido.gestionar` | Gestionar contenido | Administrar menús, carruseles, destacados y redes sociales. |
| `integraciones.ver` | Ver integraciones | Consultar la configuración y el estado de integraciones. |
| `integraciones.gestionar` | Gestionar integraciones | Configurar credenciales y proveedores de integraciones. |
| `reportes.ver` | Ver reportes | Consultar reportes de ventas y métricas. |
| `reportes.exportar` | Exportar reportes | Descargar reportes e información comercial. |
## Matriz de asignación
| Permiso | `admin` | `tenant_admin` | `user` |
| --- | :---: | :---: | :---: |
| `tenant.ver` | Sí | Sí | No |
| `tenant.editar` | Sí | Sí | No |
| `usuarios.ver` | Sí | Sí | No |
| `usuarios.gestionar` | Sí | Sí | No |
| `roles.ver` | Sí | Sí | No |
| `roles.gestionar` | Sí | No | No |
| `catalogo.ver` | Sí | Sí | No |
| `catalogo.gestionar` | Sí | Sí | No |
| `inventario.ver` | Sí | Sí | No |
| `inventario.ajustar` | Sí | Sí | No |
| `pedidos.ver` | Sí | Sí | No |
| `pedidos.gestionar` | Sí | Sí | No |
| `pedidos.cancelar` | Sí | Sí | No |
| `pagos.ver` | Sí | Sí | No |
| `pagos.gestionar` | Sí | Sí | No |
| `tickets.ver` | Sí | Sí | No |
| `tickets.gestionar` | Sí | Sí | No |
| `contenido.gestionar` | Sí | Sí | No |
| `integraciones.ver` | Sí | Sí | No |
| `integraciones.gestionar` | Sí | Sí | No |
| `reportes.ver` | Sí | Sí | No |
| `reportes.exportar` | Sí | Sí | No |
## Carga inicial
El seeder `Database\Seeders\AuthorizationSeeder` crea o actualiza este catálogo y
sincroniza sus asignaciones. Puede ejecutarse de manera independiente:
```bash
php artisan db:seed --class=AuthorizationSeeder
```
También se ejecuta como parte de `DatabaseSeeder`.
Las acciones del rol `user` no se representan como permisos administrativos. El
acceso al perfil, carrito, pedidos y tickets se autoriza comprobando que el recurso
pertenezca al usuario autenticado y al tenant actual.

View File

@@ -94,9 +94,6 @@ return [
'schema_required' => 'The schema is required for static menus.',
'not_found' => 'The specified menu does not exist.',
],
'tenant' => [
'invalid_carousel_image' => 'The specified image does not exist or is not an image attachment.',
],
'errors' => [
'forbidden' => 'You do not have permission to perform this action.',
'not_found' => 'The requested resource was not found.',

View File

@@ -94,9 +94,6 @@ return [
'schema_required' => 'El schema es obligatorio para los menús estáticos.',
'not_found' => 'El menú indicado no existe.',
],
'tenant' => [
'invalid_carousel_image' => 'La imagen indicada no existe o no es un attachment de tipo imagen.',
],
'errors' => [
'forbidden' => 'No tienes permiso para realizar esta acción.',
'not_found' => 'El recurso solicitado no fue encontrado.',

View File

@@ -0,0 +1,130 @@
<?php
namespace Tests\Feature\Auth;
use App\Domains\Auth\Models\LoginAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Role;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class AdminAppLoginControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_logs_in_a_tenant_bound_adminapp_user(): void
{
$tenant = $this->createTenant();
$this->createRole(RoleCode::AdminApp);
$user = User::factory()->create([
'email' => 'admin@example.com',
'password' => Hash::make('secret123'),
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]);
$response = $this->postJson('/api/v1/adminapp/login', [
'email' => ' ADMIN@EXAMPLE.COM ',
'password' => 'secret123',
]);
$response
->assertOk()
->assertJsonPath('code', 'auth.login_success')
->assertJsonPath('token_type', 'Bearer')
->assertJsonPath('user.id', $user->id)
->assertJsonPath('user.rol_codigo', RoleCode::AdminApp->value)
->assertJsonPath('user.tenant_codigo', $tenant->codigo);
$this->assertNotEmpty($response->json('token'));
$this->assertSame(['adminapp'], $user->tokens()->sole()->abilities);
$this->assertDatabaseHas('login_attempts', [
'user_id' => $user->id,
'tenant_codigo' => $tenant->codigo,
'outcome' => LoginAttempt::OUTCOME_SUCCESS,
]);
}
public function test_it_rejects_non_adminapp_users_as_invalid_credentials(): void
{
$this->createRole(RoleCode::User);
$user = User::factory()->create([
'email' => 'customer@example.com',
'password' => Hash::make('secret123'),
'rol_codigo' => RoleCode::User->value,
]);
$this->postJson('/api/v1/adminapp/login', [
'email' => $user->email,
'password' => 'secret123',
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
$this->assertDatabaseCount('personal_access_tokens', 0);
$this->assertSame(0, $user->refresh()->failed_login_attempts);
}
public function test_the_storefront_login_rejects_adminapp_users(): void
{
$tenant = $this->createTenant();
$this->createRole(RoleCode::AdminApp);
$user = User::factory()->create([
'email' => 'admin@example.com',
'password' => Hash::make('secret123'),
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]);
$this->postJson('/api/login', [
'email' => $user->email,
'password' => 'secret123',
'tenant_codigo' => $tenant->codigo,
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
$this->assertDatabaseCount('personal_access_tokens', 0);
}
public function test_it_rejects_an_adminapp_user_without_a_tenant(): void
{
$this->createRole(RoleCode::AdminApp);
$user = User::factory()->create([
'email' => 'unbound@example.com',
'password' => Hash::make('secret123'),
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => null,
]);
$this->postJson('/api/v1/adminapp/login', [
'email' => $user->email,
'password' => 'secret123',
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
$this->assertDatabaseCount('personal_access_tokens', 0);
}
public function test_it_validates_required_fields(): void
{
$this->postJson('/api/v1/adminapp/login', [])
->assertUnprocessable()
->assertJsonValidationErrors(['email', 'password']);
}
private function createRole(RoleCode $role): Role
{
return Role::query()->create([
'codigo' => $role->value,
'nombre' => $role->value,
]);
}
private function createTenant(): Tenant
{
return Tenant::query()->create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.test',
]);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Tests\Feature\Auth;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class UserAuthorizationRelationsTest extends TestCase
{
use RefreshDatabase;
public function test_users_table_has_role_and_tenant_codes(): void
{
$this->assertTrue(Schema::hasColumns('users', [
'rol_codigo',
'tenant_codigo',
]));
}
public function test_a_new_user_has_the_user_role_and_no_tenant_by_default(): void
{
$user = User::factory()->create();
$this->assertSame(RoleCode::User->value, $user->rol_codigo);
$this->assertNull($user->tenant_codigo);
$this->assertSame(RoleCode::User->value, $user->role->codigo);
$this->assertNull($user->tenant);
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace Tests\Feature\Authorization;
use App\Domains\Authorization\Models\Permission;
use App\Domains\Authorization\Models\Role;
use App\Domains\Authorization\Models\RolePermission;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class RolePermissionModelTest extends TestCase
{
use RefreshDatabase;
public function test_authorization_tables_have_the_expected_columns(): void
{
$this->assertEqualsCanonicalizing([
'id',
'codigo',
'nombre',
'descripcion',
'created_at',
'updated_at',
], Schema::getColumnListing('permisos'));
$this->assertEqualsCanonicalizing([
'id',
'codigo',
'nombre',
'descripcion',
'created_at',
'updated_at',
], Schema::getColumnListing('roles'));
$this->assertEqualsCanonicalizing([
'id',
'codigo_permiso',
'rol_codigo',
'created_at',
'updated_at',
], Schema::getColumnListing('roles_permisos'));
}
public function test_a_role_and_permission_can_be_related_by_their_codes(): void
{
$role = Role::query()->create([
'codigo' => 'administrador',
'nombre' => 'Administrador',
'descripcion' => 'Administra la tienda.',
]);
$permission = Permission::query()->create([
'codigo' => 'catalogo.editar',
'nombre' => 'Editar catálogo',
]);
$role->permissions()->attach($permission->codigo);
$this->assertTrue($role->permissions()->firstOrFail()->is($permission));
$this->assertTrue($permission->roles()->firstOrFail()->is($role));
$this->assertDatabaseHas('roles_permisos', [
'rol_codigo' => $role->codigo,
'codigo_permiso' => $permission->codigo,
]);
}
public function test_the_pivot_model_exposes_its_role_and_permission(): void
{
$role = Role::query()->create([
'codigo' => 'editor',
'nombre' => 'Editor',
]);
$permission = Permission::query()->create([
'codigo' => 'catalogo.editar',
'nombre' => 'Editar catálogo',
]);
$assignment = RolePermission::query()->create([
'rol_codigo' => $role->codigo,
'codigo_permiso' => $permission->codigo,
]);
$this->assertTrue($assignment->role->is($role));
$this->assertTrue($assignment->permission->is($permission));
}
public function test_a_permission_cannot_be_assigned_twice_to_the_same_role(): void
{
$role = Role::query()->create([
'codigo' => 'editor',
'nombre' => 'Editor',
]);
$permission = Permission::query()->create([
'codigo' => 'catalogo.editar',
'nombre' => 'Editar catálogo',
]);
$role->permissions()->attach($permission->codigo);
$this->expectException(QueryException::class);
$role->permissions()->attach($permission->codigo);
}
public function test_deleting_a_role_removes_its_permission_assignments(): void
{
$role = Role::query()->create([
'codigo' => 'editor',
'nombre' => 'Editor',
]);
$permission = Permission::query()->create([
'codigo' => 'catalogo.editar',
'nombre' => 'Editar catálogo',
]);
$role->permissions()->attach($permission->codigo);
$role->delete();
$this->assertDatabaseMissing('roles_permisos', [
'rol_codigo' => $role->codigo,
'codigo_permiso' => $permission->codigo,
]);
$this->assertDatabaseHas('permisos', [
'codigo' => $permission->codigo,
]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Tests\Feature\Seeders;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Permission;
use App\Domains\Authorization\Models\Role;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthorizationSeederTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_the_initial_roles_and_permissions(): void
{
$this->seed(AuthorizationSeeder::class);
$this->assertSame(
[
RoleCode::Admin->value,
RoleCode::AdminApp->value,
RoleCode::User->value,
],
Role::query()->orderBy('codigo')->pluck('codigo')->all()
);
$this->assertCount(22, Permission::query()->get());
}
public function test_it_assigns_the_expected_permissions_to_each_role(): void
{
$this->seed(AuthorizationSeeder::class);
$admin = Role::query()->where('codigo', RoleCode::Admin->value)->firstOrFail();
$appAdmin = Role::query()->where('codigo', RoleCode::AdminApp->value)->firstOrFail();
$user = Role::query()->where('codigo', RoleCode::User->value)->firstOrFail();
$this->assertCount(22, $admin->permissions);
$this->assertCount(0, $appAdmin->permissions);
$this->assertCount(0, $user->permissions);
}
public function test_it_can_run_more_than_once_without_creating_duplicates(): void
{
$this->seed(AuthorizationSeeder::class);
$this->seed(AuthorizationSeeder::class);
$this->assertCount(3, Role::query()->get());
$this->assertCount(22, Permission::query()->get());
$this->assertDatabaseCount('roles_permisos', 22);
}
}

View File

@@ -4,8 +4,11 @@ namespace Tests\Feature\Seeders;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Role;
use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant;
use Database\Seeders\AuthorizationSeeder;
use Database\Seeders\MenuSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -14,6 +17,79 @@ class MenuSeederTest extends TestCase
{
use RefreshDatabase;
public function test_it_seeds_the_admin_menus(): void
{
$tenant = $this->createTenant('admin_tenant');
$this->seed(MenuSeeder::class);
$expectedMenus = [
'admin.catalog' => ['Catálogo', '/admin/catalog'],
'admin.categories' => ['Categorías', '/admin/categories'],
'admin.combos' => ['Combos', '/admin/combos'],
'admin.event' => ['Eventos', '/admin/event'],
'admin.staff' => ['Staff', '/admin/staff'],
'admin.ventas' => ['Ventas', '/admin/ventas'],
];
$adminMenus = Menu::query()
->whereIn('code', array_keys($expectedMenus))
->get()
->keyBy('code');
$this->assertCount(count($expectedMenus), $adminMenus);
foreach ($expectedMenus as $code => [$label, $route]) {
$this->assertSame($label, $adminMenus->get($code)?->label);
$this->assertSame($route, $adminMenus->get($code)?->route);
$this->assertTrue(
$tenant->menues()->where('menues.code', $code)->exists()
);
}
}
public function test_it_assigns_menus_to_the_expected_roles(): void
{
$this->seed(AuthorizationSeeder::class);
$this->seed(MenuSeeder::class);
$allMenuCodes = Menu::query()
->orderBy('code')
->pluck('code')
->all();
$userMenuCodes = Menu::query()
->where('code', 'not like', 'admin.%')
->orderBy('code')
->pluck('code')
->all();
foreach ([RoleCode::Admin->value, RoleCode::AdminApp->value] as $roleCode) {
$roleMenuCodes = Role::query()
->where('codigo', $roleCode)
->firstOrFail()
->menus()
->orderBy('menues.code')
->pluck('menues.code')
->all();
$this->assertSame($allMenuCodes, $roleMenuCodes);
}
$userRoleMenuCodes = Role::query()
->where('codigo', RoleCode::User->value)
->firstOrFail()
->menus()
->orderBy('menues.code')
->pluck('menues.code')
->all();
$this->assertSame($userMenuCodes, $userRoleMenuCodes);
$this->assertDatabaseCount(
'roles_menues',
(count($allMenuCodes) * 2) + count($userMenuCodes)
);
}
public function test_it_seeds_the_account_menu_hierarchy(): void
{
$tenant = $this->createTenant('account_tenant');

View File

@@ -5,6 +5,7 @@ namespace Tests\Feature\Seeders;
use App\Domains\Tenant\Models\Tenant;
use Database\Seeders\SocialMediaSeeder;
use Database\Seeders\TenantSeeder;
use Database\Seeders\WebsiteTypeSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
@@ -13,38 +14,12 @@ class TenantSeederTest extends TestCase
{
use RefreshDatabase;
public function test_it_loads_the_sonder_main_carousel_images_in_order(): void
{
Storage::fake('s3');
$this->seed([
SocialMediaSeeder::class,
TenantSeeder::class,
]);
$tenant = Tenant::query()->where('codigo', 'sonder')->firstOrFail();
$images = $tenant->mainCarouselImages()->get();
$this->assertSame([
'01-urban-team.png',
'02-running-shoes.png',
'03-streetwear.png',
'04-football-training.png',
'05-activewear-essentials.png',
'06-city-runners.png',
], $images->pluck('filename')->all());
$this->assertSame([0, 1, 2, 3, 4, 5], $images->pluck('pivot.orden')->all());
foreach ($images as $image) {
Storage::disk('s3')->assertExists($image->path);
}
}
public function test_it_assigns_social_media_to_both_tenants_in_order(): void
{
Storage::fake('s3');
$this->seed([
WebsiteTypeSeeder::class,
SocialMediaSeeder::class,
TenantSeeder::class,
]);
@@ -69,4 +44,66 @@ class TenantSeederTest extends TestCase
$this->assertSame([0, 1, 2, 3], $socialMedia->pluck('pivot.orden')->all());
}
}
public function test_it_assigns_each_tenant_its_website_type_and_extras(): void
{
Storage::fake('s3');
$this->seed([
WebsiteTypeSeeder::class,
SocialMediaSeeder::class,
TenantSeeder::class,
]);
$sonder = Tenant::query()
->where('codigo', 'sonder')
->with('websiteExtras.websiteTypeExtra')
->sole();
$this->assertSame('shopit', $sonder->website_type_code);
$carousel = $sonder->websiteExtras
->firstWhere('websiteTypeExtra.codigo', 'carousel');
$this->assertNotNull($carousel);
$this->assertCount(6, $carousel->config);
foreach ($carousel->config as $attachmentId) {
$this->assertDatabaseHas('attachments', [
'id' => $attachmentId,
'type' => 'image',
]);
}
$fiesta = Tenant::query()
->where('codigo', 'fiesta_futbol_infantil')
->with('websiteExtras.websiteTypeExtra')
->sole();
$this->assertSame('onticket', $fiesta->website_type_code);
$extras = $fiesta->websiteExtras->keyBy('websiteTypeExtra.codigo');
$this->assertEqualsCanonicalizing(
['heroConfig', 'eventConfig'],
$extras->keys()->all(),
);
$heroConfig = $extras->get('heroConfig')->config;
$this->assertSame('<h1>Fiesta Fútbol Infantil</h1>', $heroConfig['title_html']);
$this->assertIsInt($heroConfig['background_image_id']);
$this->assertDatabaseHas('attachments', [
'id' => $heroConfig['background_image_id'],
'type' => 'image',
]);
$this->assertSame([
'title' => 'Fiesta Fútbol Infantil',
'location' => 'Rosario, Santa Fe',
'dates_text' => '9, 10, 11 y 12 de Octubre 2026',
'dates' => [
'2026-12-05',
'2026-12-06',
],
], $extras->get('eventConfig')->config);
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Tests\Feature\Seeders;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\WebsiteTypeSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WebsiteTypeSeederTest extends TestCase
{
use RefreshDatabase;
public function test_it_seeds_website_types_and_their_config_schemas_idempotently(): void
{
$this->seed(WebsiteTypeSeeder::class);
$this->seed(WebsiteTypeSeeder::class);
$this->assertSame(2, WebsiteType::query()->count());
$shopIt = WebsiteType::query()
->where('codigo', 'shopit')
->with('extras')
->sole();
$this->assertSame('ShopIt', $shopIt->nombre);
$this->assertSame(['carousel'], $shopIt->extras->pluck('codigo')->all());
$this->assertSame('Carrusel principal', $shopIt->extras->sole()->nombre);
$this->assertSame([
'request_rules' => [
'$' => 'required|array|max:10',
'$.*' => 'required|image_or_base64|distinct',
],
'transforms' => [
'$.*' => [
'handler' => 'attachment',
'attachment_type' => 'image',
],
],
], $shopIt->extras->sole()->config_schema);
$onTicket = WebsiteType::query()
->where('codigo', 'onticket')
->with('extras')
->sole();
$this->assertSame('OnTicket', $onTicket->nombre);
$this->assertEqualsCanonicalizing(
['heroConfig', 'eventConfig'],
$onTicket->extras->pluck('codigo')->all(),
);
$heroSchema = $onTicket->extras->firstWhere('codigo', 'heroConfig')->config_schema;
$this->assertSame([
'request_rules' => [
'$' => 'required|array',
'title_html' => 'nullable|string',
'description_html' => 'nullable|string',
'button_text' => 'nullable|string',
'button_href' => 'nullable|string',
'background_image_id' => 'nullable|image_or_base64',
],
'transforms' => [
'background_image_id' => [
'handler' => 'attachment',
'attachment_type' => 'image',
],
],
], $heroSchema);
$eventSchema = $onTicket->extras->firstWhere('codigo', 'eventConfig')->config_schema;
$this->assertSame([
'request_rules' => [
'$' => 'required|array',
'title' => 'nullable|string',
'location' => 'nullable|string',
'dates_text' => 'nullable|string|max:255',
'dates' => 'nullable|array',
'dates.*' => 'required|date_format:Y-m-d|distinct',
],
'transforms' => [],
], $eventSchema);
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace Tests\Feature\Tenant;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Role;
use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAppBootstrapTenantControllerTest extends TestCase
{
use RefreshDatabase;
public function test_authentication_is_required(): void
{
$this->getJson('/api/v1/adminapp/tenant/bootstrap')
->assertUnauthorized();
}
public function test_a_customer_cannot_bootstrap_adminapp(): void
{
$userRole = $this->createRole(RoleCode::User);
$customer = User::factory()->create([
'rol_codigo' => $userRole->codigo,
'tenant_codigo' => null,
]);
Sanctum::actingAs($customer);
$this->getJson('/api/v1/adminapp/tenant/bootstrap')
->assertForbidden();
}
public function test_it_returns_the_authenticated_users_tenant_and_admin_menus(): void
{
$adminAppRole = $this->createRole(RoleCode::AdminApp);
$tenant = $this->createTenant('acme');
$otherTenant = $this->createTenant('other');
$catalog = $this->createMenu('admin.catalog', 'Catálogo', '/admin/catalog');
$staff = $this->createMenu('admin.staff', 'Staff', '/admin/staff');
$storefront = $this->createMenu('index', 'Inicio', '/');
$adminAppRole->menus()->sync([$catalog->code, $storefront->code]);
$tenant->menues()->sync([$catalog->code, $staff->code, $storefront->code]);
$otherTenant->menues()->sync([$staff->code]);
Sanctum::actingAs(User::factory()->create([
'rol_codigo' => $adminAppRole->codigo,
'tenant_codigo' => $tenant->codigo,
]));
$this->getJson('/api/v1/adminapp/tenant/bootstrap')
->assertOk()
->assertJsonPath('data.codigo', 'acme')
->assertJsonCount(1, 'data.menues')
->assertJsonPath('data.menues.0.code', 'admin.catalog')
->assertJsonPath('data.menues.0.label', 'Catálogo')
->assertJsonPath('data.menues.0.route', '/admin/catalog')
->assertJsonMissing(['code' => 'admin.staff'])
->assertJsonMissing(['code' => 'index']);
}
private function createRole(RoleCode $role): Role
{
return Role::query()->create([
'codigo' => $role->value,
'nombre' => $role->value,
]);
}
private function createTenant(string $code): Tenant
{
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.test",
]);
}
private function createMenu(string $code, string $label, string $route): Menu
{
return Menu::query()->create([
'code' => $code,
'label' => $label,
'route' => $route,
]);
}
}

View File

@@ -0,0 +1,235 @@
<?php
namespace Tests\Feature\Tenant;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAppWebsiteExtraControllerTest extends TestCase
{
use RefreshDatabase;
private WebsiteType $websiteType;
protected function setUp(): void
{
parent::setUp();
$this->seed(AuthorizationSeeder::class);
$this->websiteType = WebsiteType::query()->create([
'codigo' => 'test-store',
'nombre' => 'Test Store',
]);
$this->websiteType->extras()->create([
'codigo' => 'contactConfig',
'nombre' => 'Configuración de contacto',
'descripcion' => 'Datos de contacto visibles en la tienda.',
'is_required' => false,
'config_schema' => [
'request_rules' => [
'$' => 'required|array',
'phone' => 'required|string|max:30',
],
'transforms' => [],
],
]);
}
public function test_authentication_is_required(): void
{
$this->getJson('/api/v1/adminapp/tenant/website-extras')
->assertUnauthorized();
}
public function test_a_customer_cannot_access_adminapp_website_extras(): void
{
$customer = User::factory()->create([
'rol_codigo' => RoleCode::User->value,
'tenant_codigo' => null,
]);
Sanctum::actingAs($customer);
$this->getJson('/api/v1/adminapp/tenant/website-extras')
->assertForbidden();
}
public function test_adminapp_user_can_read_definitions_and_current_values(): void
{
$tenant = $this->createTenant('acme');
$definition = $this->websiteType->extras()->firstOrFail();
$tenant->websiteExtras()->create([
'website_type_extra_id' => $definition->id,
'config' => ['phone' => '+54 341 555 0101'],
]);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->getJson('/api/v1/adminapp/tenant/website-extras')
->assertOk()
->assertJsonPath('data.website_type.codigo', 'test-store')
->assertJsonPath('data.definitions.contactConfig.codigo', 'contactConfig')
->assertJsonPath('data.definitions.contactConfig.nombre', 'Configuración de contacto')
->assertJsonPath('data.definitions.contactConfig.is_required', false)
->assertJsonPath('data.definitions.contactConfig.is_enabled', null)
->assertJsonPath('data.extras.contactConfig.phone', '+54 341 555 0101')
->assertJsonPath('data.resolved_extras.contactConfig.phone', '+54 341 555 0101');
}
public function test_adminapp_user_can_read_one_website_extra(): void
{
$tenant = $this->createTenant('acme');
$definition = $this->websiteType->extras()->firstOrFail();
$tenant->websiteExtras()->create([
'website_type_extra_id' => $definition->id,
'config' => ['phone' => '+54 341 555 0101'],
'is_enabled' => true,
]);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->getJson('/api/v1/adminapp/tenant/website-extras/contactConfig')
->assertOk()
->assertJsonPath('data.codigo', 'contactConfig')
->assertJsonPath('data.nombre', 'Configuración de contacto')
->assertJsonPath('data.is_enabled', true)
->assertJsonPath('data.config.phone', '+54 341 555 0101')
->assertJsonPath('data.resolved_config.phone', '+54 341 555 0101');
}
public function test_reading_an_unconfigured_website_extra_returns_not_found(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->getJson('/api/v1/adminapp/tenant/website-extras/contactConfig')
->assertNotFound();
}
public function test_adminapp_user_updates_one_extra_without_touching_other_tenants(): void
{
$tenant = $this->createTenant('acme');
$otherTenant = $this->createTenant('other');
$definition = $this->websiteType->extras()->firstOrFail();
$secondaryDefinition = $this->websiteType->extras()->create([
'codigo' => 'footerConfig',
'nombre' => 'Configuración del pie',
'descripcion' => 'Configuración adicional del pie.',
'is_required' => false,
'config_schema' => [
'request_rules' => ['$' => 'required|array'],
'transforms' => [],
],
]);
$tenant->websiteExtras()->create([
'website_type_extra_id' => $definition->id,
'config' => ['phone' => 'old'],
]);
$otherTenant->websiteExtras()->create([
'website_type_extra_id' => $definition->id,
'config' => ['phone' => 'untouched'],
]);
$tenant->websiteExtras()->create([
'website_type_extra_id' => $secondaryDefinition->id,
'config' => ['text' => 'also untouched'],
]);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->putJson('/api/v1/adminapp/tenant/website-extras/contactConfig', [
'config' => [
'phone' => '+54 341 555 9999',
],
])
->assertOk()
->assertJsonPath('data.extras.contactConfig.phone', '+54 341 555 9999');
$this->assertSame(
['phone' => '+54 341 555 9999'],
$tenant->websiteExtras()->firstOrFail()->config
);
$this->assertSame(
['phone' => 'untouched'],
$otherTenant->websiteExtras()->firstOrFail()->config
);
$this->assertSame(
['text' => 'also untouched'],
$tenant->websiteExtras()
->where('website_type_extra_id', $secondaryDefinition->id)
->firstOrFail()
->config
);
}
public function test_update_returns_not_found_for_an_unsupported_extra_code(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->putJson('/api/v1/adminapp/tenant/website-extras/unknown', [
'config' => ['enabled' => true],
])
->assertNotFound();
}
public function test_adminapp_user_can_toggle_an_existing_website_extra(): void
{
$tenant = $this->createTenant('acme');
$definition = $this->websiteType->extras()->firstOrFail();
$websiteExtra = $tenant->websiteExtras()->create([
'website_type_extra_id' => $definition->id,
'config' => ['phone' => '+54 341 555 0101'],
'is_enabled' => false,
]);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->patchJson('/api/v1/adminapp/tenant/website-extras/contactConfig/toggle')
->assertOk()
->assertJsonPath('data.definitions.contactConfig.is_enabled', true);
$this->assertTrue($websiteExtra->refresh()->is_enabled);
$this->patchJson('/api/v1/adminapp/tenant/website-extras/contactConfig/toggle')
->assertOk()
->assertJsonPath('data.definitions.contactConfig.is_enabled', false);
$this->assertFalse($websiteExtra->refresh()->is_enabled);
}
public function test_toggle_returns_not_found_for_an_extra_without_a_tenant_value(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->patchJson('/api/v1/adminapp/tenant/website-extras/contactConfig/toggle')
->assertNotFound();
}
private function createTenant(string $code): Tenant
{
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.test",
'website_type_code' => $this->websiteType->codigo,
]);
}
private function createAdminAppUser(Tenant $tenant): User
{
return User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]);
}
}

View File

@@ -4,9 +4,12 @@ namespace Tests\Feature\Tenant;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Role;
use App\Domains\Catalog\Models\Category;
use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
@@ -135,6 +138,44 @@ class BootstrapTenantControllerTest extends TestCase
->assertJsonMissing(['nombre' => 'Global']);
}
public function test_it_returns_only_enabled_website_extras_in_the_bootstrap(): void
{
$tenant = $this->createTenant();
$websiteType = WebsiteType::query()->create([
'codigo' => 'store',
'nombre' => 'Tienda',
]);
$tenant->update(['website_type_code' => $websiteType->codigo]);
$enabledExtra = $websiteType->extras()->create([
'codigo' => 'contact',
'nombre' => 'Contacto',
'descripcion' => 'Datos de contacto.',
'config_schema' => [],
]);
$disabledExtra = $websiteType->extras()->create([
'codigo' => 'banner',
'nombre' => 'Banner',
'descripcion' => 'Banner promocional.',
'config_schema' => [],
]);
$tenant->websiteExtras()->create([
'website_type_extra_id' => $enabledExtra->id,
'config' => ['phone' => '+54 341 555 0101'],
'is_enabled' => true,
]);
$tenant->websiteExtras()->create([
'website_type_extra_id' => $disabledExtra->id,
'config' => ['title' => 'No mostrar'],
'is_enabled' => false,
]);
$this->getJson('/api/tenants/bootstrap/acme.com')
->assertOk()
->assertJsonPath('data.extras.contact.phone', '+54 341 555 0101')
->assertJsonMissingPath('data.extras.banner');
}
public function test_it_returns_not_found_when_the_domain_does_not_exist(): void
{
$response = $this->getJson('/api/tenants/bootstrap/missing.example');
@@ -145,6 +186,10 @@ class BootstrapTenantControllerTest extends TestCase
public function test_it_returns_tenant_menus_as_a_clean_hierarchy(): void
{
$tenant = $this->createTenant();
$userRole = Role::query()->create([
'codigo' => RoleCode::User->value,
'nombre' => 'Usuario',
]);
$parent = Menu::query()->create([
'code' => 'help',
'label' => 'Ayuda',
@@ -181,6 +226,11 @@ class BootstrapTenantControllerTest extends TestCase
$staticChild->code => ['static_content' => $staticContent],
$dynamicChild->code,
]);
$userRole->menus()->sync([
$parent->code,
$staticChild->code,
$dynamicChild->code,
]);
$response = $this->getJson('/api/tenants/bootstrap/acme.com');
@@ -214,6 +264,38 @@ class BootstrapTenantControllerTest extends TestCase
$this->assertArrayNotHasKey('static_content', $menus[0]['submenues'][1]);
}
public function test_it_returns_only_menus_assigned_to_the_user_role(): void
{
$tenant = $this->createTenant();
$userRole = Role::query()->create([
'codigo' => RoleCode::User->value,
'nombre' => 'Usuario',
]);
$adminRole = Role::query()->create([
'codigo' => RoleCode::Admin->value,
'nombre' => 'Administrador',
]);
$userMenu = Menu::query()->create([
'code' => 'account',
'label' => 'Mi cuenta',
'route' => '/mi-cuenta',
]);
$adminMenu = Menu::query()->create([
'code' => 'admin.catalog',
'label' => 'Catálogo',
'route' => '/admin/catalog',
]);
$tenant->menues()->sync([$userMenu->code, $adminMenu->code]);
$userRole->menus()->sync([$userMenu->code]);
$adminRole->menus()->sync([$adminMenu->code]);
$this->getJson('/api/tenants/bootstrap/acme.com')
->assertOk()
->assertJsonPath('data.menues.0.code', 'account')
->assertJsonMissing(['code' => 'admin.catalog']);
}
public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void
{
$base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';

View File

@@ -0,0 +1,192 @@
<?php
namespace Tests\Feature\Tenant;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Services\TenantInformationService;
use Database\Seeders\WebsiteTypeSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class StoreTenantWithExtrasTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('s3');
$this->seed(WebsiteTypeSeeder::class);
}
public function test_it_creates_a_tenant_and_validates_and_stores_its_website_extras(): void
{
$response = $this->postJson('/api/tenants', array_merge($this->tenantData(), [
'website_type_code' => 'onticket',
'extras' => [
'eventConfig' => [
'title' => 'Festival',
'location' => 'Buenos Aires',
'dates_text' => '10 y 11 de octubre de 2026',
'dates' => ['2026-10-10', '2026-10-11'],
],
],
]));
$response
->assertCreated()
->assertJsonPath('data.website_type_code', 'onticket')
->assertJsonPath('data.website_type.codigo', 'onticket')
->assertJsonPath('data.extras.eventConfig.title', 'Festival');
$tenant = Tenant::query()->where('codigo', 'festival')->sole();
$this->assertDatabaseHas('websites_extras', [
'website_code' => $tenant->codigo,
]);
$this->assertSame(
[
'title' => 'Festival',
'location' => 'Buenos Aires',
'dates_text' => '10 y 11 de octubre de 2026',
'dates' => ['2026-10-10', '2026-10-11'],
],
$tenant->websiteExtras()->sole()->config
);
}
public function test_it_rejects_an_extra_not_supported_by_the_selected_website_type(): void
{
$this->postJson('/api/tenants', array_merge($this->tenantData(), [
'website_type_code' => 'shopit',
'extras' => [
'eventConfig' => [
'title' => 'Not supported',
],
],
]))
->assertUnprocessable()
->assertJsonValidationErrors(['extras']);
$this->assertDatabaseMissing('tenants', ['codigo' => 'festival']);
}
public function test_it_applies_the_extra_schema_rules(): void
{
$this->postJson('/api/tenants', array_merge($this->tenantData(), [
'website_type_code' => 'onticket',
'extras' => [
'eventConfig' => [
'dates' => ['not-a-date'],
],
],
]))
->assertUnprocessable()
->assertJsonValidationErrors(['extras.eventConfig.dates.0']);
$this->assertDatabaseMissing('tenants', ['codigo' => 'festival']);
}
public function test_it_transforms_extra_images_to_attachment_ids(): void
{
$image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
$response = $this->postJson('/api/tenants', array_merge($this->tenantData(), [
'website_type_code' => 'shopit',
'extras' => [
'carousel' => [$image],
],
]));
$response->assertCreated();
$tenant = Tenant::query()
->where('codigo', 'festival')
->sole();
$config = $tenant->websiteExtras()
->sole()
->config;
$this->assertCount(1, $config);
$this->assertIsInt($config[0]);
$this->assertDatabaseHas('attachments', [
'id' => $config[0],
'type' => 'image',
]);
$attachment = Attachment::query()->findOrFail($config[0]);
$carouselUrl = $response->json('data.extras.carousel.0');
$this->assertIsString($carouselUrl);
$this->assertStringContainsString($attachment->key, $carouselUrl);
app(TenantInformationService::class)->load($tenant);
$this->assertInstanceOf(
Attachment::class,
$tenant->websiteExtras->sole()->resolvedConfig()[0]
);
}
public function test_it_returns_scalar_attachment_fields_as_temporary_urls(): void
{
$image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
$response = $this->postJson('/api/tenants', array_merge($this->tenantData(), [
'website_type_code' => 'onticket',
'extras' => [
'heroConfig' => [
'title_html' => '<h1>Festival</h1>',
'background_image_id' => $image,
],
],
]));
$response->assertCreated();
$heroConfig = Tenant::query()
->where('codigo', 'festival')
->sole()
->websiteExtras()
->whereHas(
'websiteTypeExtra',
fn ($query) => $query->where('codigo', 'heroConfig')
)
->sole()
->config;
$attachment = Attachment::query()->findOrFail($heroConfig['background_image_id']);
$backgroundUrl = $response->json('data.extras.heroConfig.background_image_id');
$this->assertIsString($backgroundUrl);
$this->assertStringContainsString($attachment->key, $backgroundUrl);
}
/**
* @return array<string, mixed>
*/
private function tenantData(): array
{
$image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
return [
'codigo' => 'festival',
'nombre' => 'Festival',
'dominio' => 'festival.test',
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'success_color' => '#444444',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#ffffff',
'header_logo' => $image,
'footer_logo' => $image,
'search_product_layout' => 'column_with_image',
'search_group_layout' => 'paginated',
'search_items_per_page' => 12,
];
}
}

View File

@@ -1,191 +0,0 @@
<?php
namespace Tests\Feature\Tenant;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
class TenantMainCarouselImagesTest extends TestCase
{
use RefreshDatabase;
public function test_a_tenant_has_ordered_main_carousel_images(): void
{
$tenant = $this->createTenant();
$firstAttachment = $this->createAttachment('first.png');
$secondAttachment = $this->createAttachment('second.png');
$tenant->mainCarouselImages()->attach([
$secondAttachment->id => ['orden' => 2],
$firstAttachment->id => ['orden' => 1],
]);
$attachments = $tenant->mainCarouselImages()->get();
$this->assertCount(2, $attachments);
$this->assertTrue($attachments[0]->is($firstAttachment));
$this->assertTrue($attachments[1]->is($secondAttachment));
$this->assertSame([1, 2], $attachments->pluck('pivot.orden')->all());
}
public function test_bootstrap_returns_all_main_carousel_image_urls_in_order(): void
{
$tenant = $this->createTenant();
$firstAttachment = $this->createAttachment('first.png');
$secondAttachment = $this->createAttachment('second.png');
$tenant->mainCarouselImages()->attach([
$secondAttachment->id => ['orden' => 20],
$firstAttachment->id => ['orden' => 10],
]);
$response = $this->getJson('/api/tenants/bootstrap/acme.com');
$response
->assertOk()
->assertJsonCount(2, 'data.main_carousel_images');
$urls = $response->json('data.main_carousel_images');
$this->assertStringContainsString($firstAttachment->key, $urls[0]);
$this->assertStringContainsString($secondAttachment->key, $urls[1]);
}
public function test_show_returns_an_empty_main_carousel_images_array_when_the_tenant_has_no_images(): void
{
$tenant = $this->createTenant();
$this->getJson("/api/tenants/{$tenant->codigo}")
->assertOk()
->assertJsonPath('data.main_carousel_images', []);
}
public function test_store_uploads_and_attaches_main_carousel_images_in_request_order(): void
{
Storage::fake('s3');
$headerLogo = $this->createAttachment('header.png');
$footerLogo = $this->createAttachment('footer.png');
$response = $this
->withHeader('Accept', 'application/json')
->post('/api/tenants', [
...$this->tenantPayload(),
'header_logo' => $headerLogo->key,
'footer_logo' => $footerLogo->key,
'main_carousel_images' => [
UploadedFile::fake()->image('first-carousel.png', 1200, 300),
UploadedFile::fake()->image('second-carousel.png', 1200, 300),
],
]);
$response
->assertCreated()
->assertJsonCount(2, 'data.main_carousel_images');
$tenant = Tenant::query()->where('codigo', 'new-acme')->firstOrFail();
$images = $tenant->mainCarouselImages()->get();
$this->assertSame(
['first-carousel.png', 'second-carousel.png'],
$images->pluck('filename')->all()
);
$this->assertSame([0, 1], $images->pluck('pivot.orden')->all());
Storage::disk('s3')->assertExists($images[0]->path);
Storage::disk('s3')->assertExists($images[1]->path);
}
public function test_update_replaces_and_can_clear_main_carousel_images(): void
{
$tenant = $this->createTenant();
$oldImage = $this->createAttachment('old.png');
$firstImage = $this->createAttachment('first.png');
$secondImage = $this->createAttachment('second.png');
$tenant->mainCarouselImages()->attach($oldImage->id, ['orden' => 0]);
$this->putJson("/api/tenants/{$tenant->codigo}", [
'main_carousel_images' => [$secondImage->key, $firstImage->key],
])
->assertOk()
->assertJsonCount(2, 'data.main_carousel_images');
$this->assertSame(
[$secondImage->id, $firstImage->id],
$tenant->mainCarouselImages()->pluck('attachments.id')->all()
);
$this->assertDatabaseMissing('tenant_main_carousel_images', [
'tenant_id' => $tenant->id,
'attachment_id' => $oldImage->id,
]);
$this->putJson("/api/tenants/{$tenant->codigo}", [
'main_carousel_images' => [],
])
->assertOk()
->assertJsonPath('data.main_carousel_images', []);
$this->assertDatabaseMissing('tenant_main_carousel_images', [
'tenant_id' => $tenant->id,
]);
}
/**
* @return array<string, string>
*/
private function tenantPayload(): array
{
return [
'codigo' => 'new-acme',
'nombre' => 'New Acme',
'dominio' => 'new-acme.com',
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'success_color' => '#444444',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#ffffff',
];
}
private function createTenant(): Tenant
{
$headerLogo = $this->createAttachment('header.png');
$footerLogo = $this->createAttachment('footer.png');
return Tenant::query()->create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.com',
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'success_color' => '#444444',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#ffffff',
'header_logo_id' => $headerLogo->id,
'footer_logo_id' => $footerLogo->id,
]);
}
private function createAttachment(string $filename): Attachment
{
$key = (string) Str::uuid();
return Attachment::query()->create([
'key' => $key,
'path' => "tenants/main-carousel/{$key}.png",
'filename' => $filename,
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
'extension' => 'png',
'size' => 100,
]);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Tests\Feature\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class TenantPresentationConfigRemovalTest extends TestCase
{
use RefreshDatabase;
public function test_legacy_presentation_config_is_not_part_of_tenants(): void
{
$this->assertFalse(Schema::hasTable('tenant_main_carousel_images'));
$this->assertFalse(Schema::hasColumn('tenants', 'hero_bg_image_id'));
$this->assertFalse(Schema::hasColumn('tenants', 'hero_config'));
$this->assertFalse(Schema::hasColumn('tenants', 'event_config'));
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace Tests\Feature\Tenant;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class WebsiteExtrasTest extends TestCase
{
use RefreshDatabase;
public function test_website_extra_tables_have_the_expected_columns(): void
{
$this->assertTrue(Schema::hasColumn('tenants', 'website_type_code'));
$this->assertEqualsCanonicalizing([
'id',
'codigo',
'nombre',
'created_at',
'updated_at',
], Schema::getColumnListing('website_type'));
$this->assertEqualsCanonicalizing([
'id',
'website_type_code',
'codigo',
'nombre',
'descripcion',
'is_required',
'config_schema',
'created_at',
'updated_at',
], Schema::getColumnListing('website_type_extras'));
$this->assertEqualsCanonicalizing([
'id',
'website_code',
'website_type_extra_id',
'config',
'is_enabled',
'created_at',
'updated_at',
], Schema::getColumnListing('websites_extras'));
}
public function test_models_map_the_diagram_relations_and_casts(): void
{
$type = WebsiteType::query()->create([
'codigo' => 'store',
'nombre' => 'Tienda',
]);
$typeExtra = $type->extras()->create([
'codigo' => 'whatsapp',
'nombre' => 'WhatsApp',
'descripcion' => 'Configuracion del canal de WhatsApp',
'is_required' => true,
'config_schema' => [
'type' => 'object',
'required' => ['phone'],
],
]);
$tenant = $this->createTenant();
$tenant->websiteType()->associate($type)->save();
$websiteExtra = $tenant->websiteExtras()->create([
'website_type_extra_id' => $typeExtra->id,
'config' => ['phone' => '+5491112345678'],
'is_enabled' => true,
]);
$this->assertTrue($type->extras()->firstOrFail()->is($typeExtra));
$this->assertSame($type->codigo, $typeExtra->website_type_code);
$this->assertSame('whatsapp', $typeExtra->codigo);
$this->assertTrue($type->tenants()->firstOrFail()->is($tenant));
$this->assertTrue($tenant->websiteType()->firstOrFail()->is($type));
$this->assertSame($type->codigo, $tenant->website_type_code);
$this->assertTrue($typeExtra->websiteType()->firstOrFail()->is($type));
$this->assertTrue($typeExtra->websiteExtras()->firstOrFail()->is($websiteExtra));
$this->assertTrue($websiteExtra->website()->firstOrFail()->is($tenant));
$this->assertTrue($websiteExtra->websiteTypeExtra()->firstOrFail()->is($typeExtra));
$this->assertTrue($typeExtra->is_required);
$this->assertSame([
'type' => 'object',
'required' => ['phone'],
], $typeExtra->config_schema);
$this->assertSame(['phone' => '+5491112345678'], $websiteExtra->config);
$this->assertTrue($websiteExtra->is_enabled);
}
public function test_deleting_a_type_cascades_its_definitions_and_website_values(): void
{
$type = WebsiteType::query()->create([
'codigo' => 'store',
'nombre' => 'Tienda',
]);
$typeExtra = $type->extras()->create([
'codigo' => 'whatsapp',
'nombre' => 'WhatsApp',
'descripcion' => 'Configuracion del canal de WhatsApp',
'config_schema' => ['type' => 'object'],
]);
$websiteExtra = $this->createTenant()->websiteExtras()->create([
'website_type_extra_id' => $typeExtra->id,
'config' => ['phone' => '+5491112345678'],
]);
$type->delete();
$this->assertDatabaseMissing('website_type_extras', ['id' => $typeExtra->id]);
$this->assertDatabaseMissing('websites_extras', ['id' => $websiteExtra->id]);
}
private function createTenant(): Tenant
{
return Tenant::query()->create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.com',
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'success_color' => '#444444',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#ffffff',
'header_logo_id' => 1,
'footer_logo_id' => 2,
]);
}
}