Compare commits
27 Commits
5107d858cc
...
authorizat
| Author | SHA1 | Date | |
|---|---|---|---|
| 40a7dece11 | |||
| 5ec65ce3d3 | |||
| b7e1332b8c | |||
| a087a511e0 | |||
| a20b1713d3 | |||
| d56d387933 | |||
| cba32e90e7 | |||
| a674bcfabc | |||
| ace3f136e4 | |||
| 2cc72be850 | |||
| 8e3ee7eff6 | |||
| 27d4f9fac6 | |||
| 9e3163b74f | |||
| b9ad2e589f | |||
| 5416c0fd61 | |||
| 04bf03fc2e | |||
| 7c9ebc6d4d | |||
| 45553dc514 | |||
| 1c870d9cfa | |||
| 8ab94ec61f | |||
| 4eacff86bc | |||
| 5c457db1c9 | |||
| 8dc80411d7 | |||
| e16d91f3f9 | |||
| 89f6dab0c4 | |||
| ff9d7728e8 | |||
| 754aeebe3a |
@@ -10,8 +10,8 @@ PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
|
||||
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
|
||||
FRONTEND_URLS=http://localhost:4200
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_LOCALE=es
|
||||
APP_FALLBACK_LOCALE=es
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
@@ -25,6 +25,11 @@ GOOGLE_REDIRECT_URI=http://localhost/auth/google/callback
|
||||
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
AUTH_MAX_LOGIN_ATTEMPTS=5
|
||||
AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
|
||||
AUTH_LOGIN_LOCK_MINUTES=15
|
||||
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
|
||||
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -24,7 +24,8 @@ class CreateResetPasswordAttemptController extends Controller
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Si el email está registrado, recibirás un código para recuperar tu contraseña.',
|
||||
'code' => 'auth.password_reset_requested',
|
||||
'message' => __('api.auth.password_reset_requested'),
|
||||
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||
], 202);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ class GoogleTokenExchangeController extends Controller
|
||||
);
|
||||
|
||||
$response = response()->json([
|
||||
'message' => 'Sesion iniciada correctamente.',
|
||||
'code' => 'auth.login_success',
|
||||
'message' => __('api.auth.login_success'),
|
||||
'token' => $authentication['token'],
|
||||
'token_type' => 'Bearer',
|
||||
'user' => UserResource::make($authentication['user']),
|
||||
|
||||
@@ -2,35 +2,31 @@
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Auth\Requests\LoginUserRequest;
|
||||
use App\Domains\Auth\Resources\UserResource;
|
||||
use App\Domains\Auth\Services\PasswordLoginService;
|
||||
use App\Domains\Cart\Services\GuestCartMergeService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GuestCartMergeService $guestCartMergeService,
|
||||
private readonly PasswordLoginService $passwordLoginService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function __invoke(LoginUserRequest $request): JsonResponse
|
||||
{
|
||||
$credentials = $request->validated();
|
||||
$user = User::query()->where('email', $credentials['email'])->first();
|
||||
|
||||
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => 'Email o Contraseña incorrectos.',
|
||||
]);
|
||||
}
|
||||
$user = $this->passwordLoginService->authenticate(
|
||||
$credentials['email'],
|
||||
$credentials['password'],
|
||||
$credentials['tenant_codigo'],
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
);
|
||||
|
||||
$expirationMinutes = (int) config('sanctum.expiration');
|
||||
$token = $user->createToken(
|
||||
@@ -50,7 +46,8 @@ class LoginController extends Controller
|
||||
);
|
||||
|
||||
$response = response()->json([
|
||||
'message' => 'Sesion iniciada correctamente.',
|
||||
'code' => 'auth.login_success',
|
||||
'message' => __('api.auth.login_success'),
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
'user' => UserResource::make($user),
|
||||
|
||||
@@ -23,7 +23,8 @@ class LogoutController extends Controller
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Sesion cerrada correctamente.',
|
||||
'code' => 'auth.logout_success',
|
||||
'message' => __('api.auth.logout_success'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,17 @@ class RegisterController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected RegisterUserService $registerUserService,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function __invoke(RegisterUserRequest $request): JsonResponse
|
||||
{
|
||||
$user = $this->registerUserService->register($request->validated());
|
||||
|
||||
return UserResource::make($user)
|
||||
->additional(['message' => 'Usuario registrado correctamente.'])
|
||||
->additional([
|
||||
'code' => 'auth.register_success',
|
||||
'message' => __('api.auth.register_success'),
|
||||
])
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
@@ -28,12 +28,13 @@ class ResetPasswordController extends Controller
|
||||
$data['password'],
|
||||
)) {
|
||||
throw ValidationException::withMessages([
|
||||
'codigo' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
|
||||
'codigo' => __('api.auth.password_reset_invalid'),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Contraseña modificada correctamente.',
|
||||
'code' => 'auth.password_updated',
|
||||
'message' => __('api.auth.password_updated'),
|
||||
'status' => ResetPasswordAttempt::STATUS_USED,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -27,12 +27,13 @@ class ValidateResetPasswordAttemptController extends Controller
|
||||
$data['codigo'],
|
||||
)) {
|
||||
throw ValidationException::withMessages([
|
||||
'codigo' => 'El código ingresado es inválido.',
|
||||
'codigo' => __('api.auth.reset_code_invalid'),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Código validado correctamente.',
|
||||
'code' => 'auth.reset_code_valid',
|
||||
'message' => __('api.auth.reset_code_valid'),
|
||||
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||
]);
|
||||
}
|
||||
|
||||
20
app/Domains/Auth/Exceptions/AccountLockedException.php
Normal file
20
app/Domains/Auth/Exceptions/AccountLockedException.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Exceptions;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use RuntimeException;
|
||||
|
||||
class AccountLockedException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly CarbonImmutable $lockedUntil,
|
||||
) {
|
||||
parent::__construct('The account is temporarily locked.');
|
||||
}
|
||||
|
||||
public function retryAfterSeconds(): int
|
||||
{
|
||||
return max(1, (int) now()->diffInSeconds($this->lockedUntil, false));
|
||||
}
|
||||
}
|
||||
40
app/Domains/Auth/Models/LoginAttempt.php
Normal file
40
app/Domains/Auth/Models/LoginAttempt.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'user_id',
|
||||
'email_fingerprint',
|
||||
'tenant_codigo',
|
||||
'outcome',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
])]
|
||||
class LoginAttempt extends Model
|
||||
{
|
||||
public const OUTCOME_SUCCESS = 'success';
|
||||
|
||||
public const OUTCOME_INVALID_CREDENTIALS = 'invalid_credentials';
|
||||
|
||||
public const OUTCOME_ACCOUNT_LOCKED = 'account_locked';
|
||||
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -29,6 +37,28 @@ class User extends Authenticatable
|
||||
return $this->hasMany(ResetPasswordAttempt::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<LoginAttempt, $this> */
|
||||
public function loginAttempts(): HasMany
|
||||
{
|
||||
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>
|
||||
*/
|
||||
@@ -37,6 +67,9 @@ class User extends Authenticatable
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'failed_login_attempts' => 'integer',
|
||||
'last_failed_login_at' => 'datetime',
|
||||
'locked_until' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Auth\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LoginUserRequest extends FormRequest
|
||||
{
|
||||
@@ -11,6 +12,17 @@ class LoginUserRequest extends FormRequest
|
||||
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>
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class GoogleAuthService
|
||||
|
||||
if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) {
|
||||
throw ValidationException::withMessages([
|
||||
'tenant' => 'El tenant o la URL de retorno no son validos.',
|
||||
'tenant' => __('api.auth.invalid_tenant_or_return_url'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -50,13 +50,13 @@ class GoogleAuthService
|
||||
$context = Str::isUuid($state) ? Cache::pull("google-oauth-context:{$state}") : null;
|
||||
|
||||
if (! is_array($context) || ! isset($context['tenant_codigo'], $context['return_url'])) {
|
||||
abort(400, 'La solicitud de autenticacion expiro. Intenta nuevamente.');
|
||||
abort(400, __('api.auth.request_expired'));
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->where('codigo', $context['tenant_codigo'])->first();
|
||||
|
||||
if (! $tenant || ! $this->isTenantReturnUrl($context['return_url'], $tenant)) {
|
||||
abort(400, 'La URL de retorno no es valida.');
|
||||
abort(400, __('api.auth.invalid_return_url'));
|
||||
}
|
||||
|
||||
/** @var SocialiteUser $googleUser */
|
||||
@@ -88,13 +88,13 @@ class GoogleAuthService
|
||||
|
||||
if (! $authentication) {
|
||||
throw ValidationException::withMessages([
|
||||
'oauth_code' => 'El codigo de autenticacion expiro o ya fue utilizado.',
|
||||
'oauth_code' => __('api.auth.oauth_code_expired'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (($authentication['tenant_codigo'] ?? null) !== $tenantCodigo) {
|
||||
throw ValidationException::withMessages([
|
||||
'tenant_codigo' => 'El tenant no coincide con la solicitud de autenticacion.',
|
||||
'tenant_codigo' => __('api.auth.tenant_mismatch'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ class GoogleAuthService
|
||||
|
||||
if (! is_string($googleId) || $googleId === '' || ! is_string($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw ValidationException::withMessages([
|
||||
'google' => 'Google no devolvio una identidad valida.',
|
||||
'google' => __('api.auth.google_email_required'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ class GoogleAuthService
|
||||
$emailVerified = $rawUser['email_verified'] ?? $rawUser['verified_email'] ?? false;
|
||||
if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'google' => 'La cuenta de Google debe tener el email verificado.',
|
||||
'google' => __('api.auth.google_email_unverified'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
173
app/Domains/Auth/Services/PasswordLoginService.php
Normal file
173
app/Domains/Auth/Services/PasswordLoginService.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Services;
|
||||
|
||||
use App\Domains\Auth\Exceptions\AccountLockedException;
|
||||
use App\Domains\Auth\Models\LoginAttempt;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PasswordLoginService
|
||||
{
|
||||
/**
|
||||
* @throws AccountLockedException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function authenticate(
|
||||
string $email,
|
||||
string $password,
|
||||
string $tenantCode,
|
||||
?string $ipAddress,
|
||||
?string $userAgent,
|
||||
): User {
|
||||
$normalizedEmail = mb_strtolower(trim($email));
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
/** @var array{outcome: string, user: User|null, locked_until: CarbonImmutable|null} $result */
|
||||
$result = DB::transaction(function () use (
|
||||
$normalizedEmail,
|
||||
$password,
|
||||
$tenantCode,
|
||||
$ipAddress,
|
||||
$userAgent,
|
||||
$now,
|
||||
): array {
|
||||
$user = User::query()
|
||||
->where('email', $normalizedEmail)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($user?->locked_until?->isFuture()) {
|
||||
$this->recordAttempt(
|
||||
$user,
|
||||
$normalizedEmail,
|
||||
$tenantCode,
|
||||
LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
|
||||
$ipAddress,
|
||||
$userAgent,
|
||||
);
|
||||
|
||||
return [
|
||||
'outcome' => LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
|
||||
'user' => $user,
|
||||
'locked_until' => CarbonImmutable::instance($user->locked_until),
|
||||
];
|
||||
}
|
||||
|
||||
if ($user !== null && $user->locked_until !== null) {
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 0,
|
||||
'last_failed_login_at' => null,
|
||||
'locked_until' => null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
if ($user === null || ! Hash::check($password, $user->password)) {
|
||||
if ($user !== null) {
|
||||
$this->registerFailure($user, $now);
|
||||
}
|
||||
|
||||
$outcome = $user?->locked_until?->isFuture()
|
||||
? LoginAttempt::OUTCOME_ACCOUNT_LOCKED
|
||||
: LoginAttempt::OUTCOME_INVALID_CREDENTIALS;
|
||||
$this->recordAttempt(
|
||||
$user,
|
||||
$normalizedEmail,
|
||||
$tenantCode,
|
||||
$outcome,
|
||||
$ipAddress,
|
||||
$userAgent,
|
||||
);
|
||||
|
||||
return [
|
||||
'outcome' => $outcome,
|
||||
'user' => $user,
|
||||
'locked_until' => $user?->locked_until === null
|
||||
? null
|
||||
: CarbonImmutable::instance($user->locked_until),
|
||||
];
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 0,
|
||||
'last_failed_login_at' => null,
|
||||
'locked_until' => null,
|
||||
])->save();
|
||||
|
||||
$this->recordAttempt(
|
||||
$user,
|
||||
$normalizedEmail,
|
||||
$tenantCode,
|
||||
LoginAttempt::OUTCOME_SUCCESS,
|
||||
$ipAddress,
|
||||
$userAgent,
|
||||
);
|
||||
|
||||
return [
|
||||
'outcome' => LoginAttempt::OUTCOME_SUCCESS,
|
||||
'user' => $user,
|
||||
'locked_until' => null,
|
||||
];
|
||||
});
|
||||
|
||||
if ($result['outcome'] === LoginAttempt::OUTCOME_ACCOUNT_LOCKED) {
|
||||
throw new AccountLockedException($result['locked_until']);
|
||||
}
|
||||
|
||||
if ($result['outcome'] === LoginAttempt::OUTCOME_INVALID_CREDENTIALS) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('api.auth.invalid_credentials'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $result['user'];
|
||||
}
|
||||
|
||||
private function registerFailure(User $user, CarbonImmutable $now): void
|
||||
{
|
||||
$windowMinutes = max(1, (int) config('login-security.attempt_window_minutes'));
|
||||
$maxAttempts = max(1, (int) config('login-security.max_attempts'));
|
||||
$lockMinutes = max(1, (int) config('login-security.lock_minutes'));
|
||||
|
||||
$withinAttemptWindow = $user->last_failed_login_at !== null
|
||||
&& $user->last_failed_login_at->gte($now->subMinutes($windowMinutes));
|
||||
$attempts = $withinAttemptWindow
|
||||
? $user->failed_login_attempts + 1
|
||||
: 1;
|
||||
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => $attempts,
|
||||
'last_failed_login_at' => $now,
|
||||
'locked_until' => $attempts >= $maxAttempts
|
||||
? $now->addMinutes($lockMinutes)
|
||||
: null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
private function recordAttempt(
|
||||
?User $user,
|
||||
string $normalizedEmail,
|
||||
string $tenantCode,
|
||||
string $outcome,
|
||||
?string $ipAddress,
|
||||
?string $userAgent,
|
||||
): void {
|
||||
LoginAttempt::query()->create([
|
||||
'user_id' => $user?->getKey(),
|
||||
'email_fingerprint' => hash_hmac(
|
||||
'sha256',
|
||||
$normalizedEmail,
|
||||
(string) config('app.key'),
|
||||
),
|
||||
'tenant_codigo' => $tenantCode,
|
||||
'outcome' => $outcome,
|
||||
'ip_address' => $ipAddress,
|
||||
'user_agent' => $userAgent === null
|
||||
? null
|
||||
: mb_substr($userAgent, 0, 1024),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,9 @@ class ResetPasswordAttemptService
|
||||
}
|
||||
|
||||
$user->password = $password;
|
||||
$user->failed_login_attempts = 0;
|
||||
$user->last_failed_login_at = null;
|
||||
$user->locked_until = null;
|
||||
$user->save();
|
||||
$user->tokens()->delete();
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/register', RegisterController::class);
|
||||
Route::post('/login', LoginController::class);
|
||||
Route::post('/login', LoginController::class)->middleware('throttle:login');
|
||||
Route::post('/password/reset-attempts', CreateResetPasswordAttemptController::class)
|
||||
->middleware('throttle:5,1');
|
||||
Route::post('/password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
||||
|
||||
10
app/Domains/Authorization/Enums/RoleCode.php
Normal file
10
app/Domains/Authorization/Enums/RoleCode.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Authorization\Enums;
|
||||
|
||||
enum RoleCode: string
|
||||
{
|
||||
case Admin = 'admin';
|
||||
case AdminApp = 'adminapp';
|
||||
case User = 'user';
|
||||
}
|
||||
32
app/Domains/Authorization/Models/Permission.php
Normal file
32
app/Domains/Authorization/Models/Permission.php
Normal 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();
|
||||
}
|
||||
}
|
||||
59
app/Domains/Authorization/Models/Role.php
Normal file
59
app/Domains/Authorization/Models/Role.php
Normal 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);
|
||||
}
|
||||
}
|
||||
32
app/Domains/Authorization/Models/RolePermission.php
Normal file
32
app/Domains/Authorization/Models/RolePermission.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,10 @@ class CartController extends Controller
|
||||
);
|
||||
|
||||
$response = CartResource::make($result['cart'])
|
||||
->additional(['message' => 'Producto agregado al carrito.'])
|
||||
->additional([
|
||||
'code' => 'cart.item_added',
|
||||
'message' => __('api.cart.item_added'),
|
||||
])
|
||||
->response();
|
||||
|
||||
if ($result['guest_token'] !== null) {
|
||||
@@ -58,13 +61,19 @@ class CartController extends Controller
|
||||
$cartItem->getKey(),
|
||||
(int) $request->validated('cantidad'),
|
||||
)
|
||||
)->additional(['message' => 'Cantidad de producto actualizada.']);
|
||||
)->additional([
|
||||
'code' => 'cart.quantity_updated',
|
||||
'message' => __('api.cart.quantity_updated'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
|
||||
{
|
||||
return CartResource::make(
|
||||
$this->cartService->removeItem($tenant, $request, $cartItem->getKey())
|
||||
)->additional(['message' => 'Producto eliminado del carrito.']);
|
||||
)->additional([
|
||||
'code' => 'cart.item_removed',
|
||||
'message' => __('api.cart.item_removed'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ class Cart extends Model
|
||||
{
|
||||
if ($quantity <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => 'La cantidad debe ser mayor a cero.',
|
||||
'cantidad' => __('api.cart.positive_quantity'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ class Cart extends Model
|
||||
|
||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => "Stock insuficiente para el producto solicitado. Maximo disponible: {$availableQuantity}.",
|
||||
'cantidad' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ class Cart extends Model
|
||||
{
|
||||
if ($quantity <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => 'La cantidad debe ser mayor a cero.',
|
||||
'cantidad' => __('api.cart.positive_quantity'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ class Cart extends Model
|
||||
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
||||
$maxAvailable = $availableQuantity + $item->cantidad;
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.",
|
||||
'cantidad' => __('api.cart.max_quantity', ['max' => $maxAvailable]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -209,13 +209,13 @@ class Cart extends Model
|
||||
if ($catalogItem->isBundle()) {
|
||||
if ($variantId !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'variant_id' => 'Un bundle no admite una variante.',
|
||||
'variant_id' => __('api.cart.bundle_variant_forbidden'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $catalogItem->bundleComponents()->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'catalog_item_id' => 'El bundle no tiene componentes.',
|
||||
'catalog_item_id' => __('api.cart.empty_bundle'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ class Cart extends Model
|
||||
if ($variantId === null) {
|
||||
if ($catalogItem->inventory_id === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'variant_id' => 'Debe seleccionar una variante para este ítem.',
|
||||
'variant_id' => __('api.cart.variant_required'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Catalog\Models\FeaturedItem;
|
||||
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
|
||||
use App\Domains\Catalog\Requests\CategoryPageRequest;
|
||||
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
|
||||
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
|
||||
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
|
||||
@@ -57,6 +59,32 @@ class CatalogController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function category(
|
||||
CategoryPageRequest $request,
|
||||
Tenant $tenant,
|
||||
Category $category,
|
||||
CatalogService $catalogService,
|
||||
): AnonymousResourceCollection {
|
||||
abort_unless($category->tenant_code === $tenant->codigo, 404);
|
||||
|
||||
return CatalogSearchItemResource::collection(
|
||||
$catalogService->categoryItems(
|
||||
$tenant,
|
||||
$category,
|
||||
$tenant->search_items_per_page,
|
||||
(int) $request->validated('page', 1),
|
||||
)
|
||||
)->additional([
|
||||
'category' => [
|
||||
'id' => $category->id,
|
||||
'nombre' => $category->nombre,
|
||||
'categoria_id' => $category->categoria_id,
|
||||
],
|
||||
'layout' => $tenant->search_product_layout->value,
|
||||
'group_layout' => $tenant->search_group_layout->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function featuredGroupItems(
|
||||
FeaturedGroupPageRequest $request,
|
||||
Tenant $tenant,
|
||||
|
||||
21
app/Domains/Catalog/Requests/CategoryPageRequest.php
Normal file
21
app/Domains/Catalog/Requests/CategoryPageRequest.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CategoryPageRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, list<string>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'page' => ['sometimes', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Domains\Attachable\Services\AttachmentService;
|
||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\ItemAttribute;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
@@ -45,7 +46,7 @@ class CatalogService
|
||||
} else {
|
||||
if (array_key_exists('components', $data)) {
|
||||
throw ValidationException::withMessages([
|
||||
'components' => ['Un item standard no puede tener componentes.'],
|
||||
'components' => [__('api.catalog.standard_with_components')],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -210,6 +211,29 @@ class CatalogService
|
||||
]));
|
||||
}
|
||||
|
||||
/** @return LengthAwarePaginator<CatalogItem> */
|
||||
public function categoryItems(
|
||||
Tenant $tenant,
|
||||
Category $category,
|
||||
int $perPage,
|
||||
int $page,
|
||||
): LengthAwarePaginator {
|
||||
return CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('category_id', $category->id)
|
||||
->with([
|
||||
'attachments',
|
||||
'inventory',
|
||||
'variants.inventory',
|
||||
'variants.attachments',
|
||||
'variants.definitions.itemAttribute.attribute',
|
||||
'bundleComponents.catalogItem',
|
||||
'bundleComponents.variant.catalogItem',
|
||||
])
|
||||
->orderBy('nombre')
|
||||
->paginate(perPage: $perPage, pageName: 'page', page: $page);
|
||||
}
|
||||
|
||||
public function delete(CatalogItem $catalogItem): void
|
||||
{
|
||||
DB::transaction(function () use ($catalogItem): void {
|
||||
@@ -265,7 +289,7 @@ class CatalogService
|
||||
|
||||
if (isset($seen[$key])) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}" => ['El componente esta duplicado.'],
|
||||
"components.{$index}" => [__('api.catalog.duplicate_component')],
|
||||
]);
|
||||
}
|
||||
$seen[$key] = true;
|
||||
@@ -278,7 +302,7 @@ class CatalogService
|
||||
if ($componentItem === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.catalog_item_id" => [
|
||||
'El item no pertenece al tenant del bundle.',
|
||||
__('api.catalog.component_wrong_tenant'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -286,7 +310,7 @@ class CatalogService
|
||||
if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.catalog_item_id" => [
|
||||
'El componente debe ser un item standard distinto del bundle.',
|
||||
__('api.catalog.invalid_component'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -295,7 +319,7 @@ class CatalogService
|
||||
if ($hasVariants && $variantId === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.variant_id" => [
|
||||
'Debe seleccionar una variante para este componente.',
|
||||
__('api.catalog.component_variant_required'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -303,7 +327,7 @@ class CatalogService
|
||||
if (! $hasVariants && $variantId !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.variant_id" => [
|
||||
'El componente con inventario directo no admite una variante.',
|
||||
__('api.catalog.component_variant_forbidden'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -311,7 +335,7 @@ class CatalogService
|
||||
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.variant_id" => [
|
||||
'La variante no pertenece al componente indicado.',
|
||||
__('api.catalog.component_variant_invalid'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -333,7 +357,7 @@ class CatalogService
|
||||
{
|
||||
if ($components === []) {
|
||||
throw ValidationException::withMessages([
|
||||
'components' => ['Un bundle debe tener al menos un componente.'],
|
||||
'components' => [__('api.catalog.bundle_component_required')],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -348,7 +372,7 @@ class CatalogService
|
||||
] as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
throw ValidationException::withMessages([
|
||||
$field => ["{$field} no se admite para un bundle."],
|
||||
$field => [__('api.catalog.bundle_field_forbidden', ['field' => $field])],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -376,7 +400,7 @@ class CatalogService
|
||||
|
||||
if ($attachment === null) {
|
||||
throw ValidationException::withMessages([
|
||||
$validationKey => ['El attachment indicado no existe.'],
|
||||
$validationKey => [__('api.catalog.attachment_not_found')],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -408,7 +432,7 @@ class CatalogService
|
||||
if ($attribute === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'attribute_codes' => [
|
||||
"El atributo {$attributeCode} no existe para el tenant del ítem.",
|
||||
__('api.catalog.attribute_not_found', ['attribute' => $attributeCode]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -442,7 +466,7 @@ class CatalogService
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.inventory" => [
|
||||
'inventory_id, reserved_stock y sold_units son administrados internamente.',
|
||||
__('api.catalog.managed_inventory_fields'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -463,7 +487,7 @@ class CatalogService
|
||||
if ($itemAttribute === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.values.{$attributeCode}" => [
|
||||
'El atributo no pertenece al ítem de catálogo.',
|
||||
__('api.catalog.attribute_not_on_item'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -489,7 +513,7 @@ class CatalogService
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.maximum_use_date" => [
|
||||
'La fecha maxima efectiva debe ser posterior o igual a la fecha minima efectiva.',
|
||||
__('api.catalog.invalid_effective_date_range'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -508,7 +532,7 @@ class CatalogService
|
||||
if ($hasVariants && $variants === []) {
|
||||
throw ValidationException::withMessages([
|
||||
'variants' => [
|
||||
'Un ítem con attribute_codes debe tener variantes.',
|
||||
__('api.catalog.variants_required'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -516,7 +540,7 @@ class CatalogService
|
||||
if (! $hasVariants && $variants !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'variants' => [
|
||||
'Un ítem sin attribute_codes no puede tener variantes.',
|
||||
__('api.catalog.variants_forbidden'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -524,7 +548,7 @@ class CatalogService
|
||||
if ($hasVariants && $hasDirectStock) {
|
||||
throw ValidationException::withMessages([
|
||||
'real_stock' => [
|
||||
'Un ítem con variantes no puede tener inventario directo.',
|
||||
__('api.catalog.direct_inventory_forbidden'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -536,7 +560,7 @@ class CatalogService
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'inventory' => [
|
||||
'inventory_id, reserved_stock y sold_units son administrados internamente.',
|
||||
__('api.catalog.managed_inventory_fields'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
||||
->name('catalog.featured-groups.items.index');
|
||||
Route::get('catalog-items', [CatalogController::class, 'search'])
|
||||
->name('catalog-items.index');
|
||||
Route::get('categories/{category}', [CatalogController::class, 'category'])
|
||||
->name('categories.show');
|
||||
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
|
||||
Route::post('catalog-items', [CatalogController::class, 'store']);
|
||||
});
|
||||
|
||||
@@ -6,28 +6,26 @@ use App\Domains\Integration\Requests\TelepagosWebhookRequest;
|
||||
use App\Domains\Integration\Services\TelepagosWebhookService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TelepagosWebhookController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Telepagos webhook.
|
||||
*
|
||||
* @param TelepagosWebhookRequest $request
|
||||
* @param string $tenantCodigo
|
||||
* @param TelepagosWebhookService $service
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function handle(TelepagosWebhookRequest $request, string $tenantCodigo, TelepagosWebhookService $service): JsonResponse
|
||||
{
|
||||
try {
|
||||
$cashinId = $request->validated('id');
|
||||
|
||||
|
||||
$service->handleWebhook($tenantCodigo, $cashinId);
|
||||
|
||||
|
||||
return response()->json(['status' => 'success']);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['status' => 'error', 'message' => $e->getMessage()], 500);
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'code' => 'integration.webhook_failed',
|
||||
'message' => __('api.integration.webhook_failed'),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,12 @@ class TenantIntegrationController extends Controller
|
||||
public function show(string $tenantCode, string $integrationCode)
|
||||
{
|
||||
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
|
||||
|
||||
if (!$integration) {
|
||||
return response()->json(['message' => 'Integration not configured for this tenant'], 404);
|
||||
|
||||
if (! $integration) {
|
||||
return response()->json([
|
||||
'code' => 'integration.not_configured',
|
||||
'message' => __('api.integration.not_configured'),
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json($integration);
|
||||
@@ -35,7 +38,7 @@ class TenantIntegrationController extends Controller
|
||||
public function store(StoreTenantIntegrationRequest $request, string $tenantCode, string $integrationCode)
|
||||
{
|
||||
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
|
||||
|
||||
|
||||
try {
|
||||
$this->tenantIntegrationService->updateOrCreateIntegration(
|
||||
$tenantCode,
|
||||
@@ -44,11 +47,13 @@ class TenantIntegrationController extends Controller
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'integration configured correctly',
|
||||
'code' => 'integration.configured',
|
||||
'message' => __('api.integration.configured'),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'message' => 'Error validando la configuración: ' . $e->getMessage()
|
||||
'code' => 'integration.validation_failed',
|
||||
'message' => __('api.integration.validation_failed', ['error' => $e->getMessage()]),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ class StoreTenantIntegrationRequest extends FormRequest
|
||||
$integrationCode = $this->route('integration_code');
|
||||
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first();
|
||||
|
||||
if (!$this->integrationModel) {
|
||||
if (! $this->integrationModel) {
|
||||
throw ValidationException::withMessages([
|
||||
'integration_code' => 'Integration not found.'
|
||||
'integration_code' => __('api.integration.not_configured'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class StoreTenantIntegrationRequest extends FormRequest
|
||||
// Dynamic validation rules based on the integration data schema
|
||||
if ($this->integrationModel && $this->integrationModel->integration_data_schema) {
|
||||
foreach ($this->integrationModel->integration_data_schema as $field => $rule) {
|
||||
$rules['integration_data.' . $field] = $rule;
|
||||
$rules['integration_data.'.$field] = $rule;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Domains\Purchase\Models\TelepagosQr;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TelepagosWebhookService
|
||||
@@ -19,16 +20,13 @@ class TelepagosWebhookService
|
||||
/**
|
||||
* Handle the Telepagos webhook notification.
|
||||
*
|
||||
* @param string $tenantCodigo
|
||||
* @param string $cashinId
|
||||
* @return void
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handleWebhook(string $tenantCodigo, string $cashinId): void
|
||||
{
|
||||
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
|
||||
|
||||
$telepagosService = new TelepagosIntegrationService();
|
||||
$telepagosService = new TelepagosIntegrationService;
|
||||
$telepagosService->forTenant($tenant->codigo);
|
||||
|
||||
try {
|
||||
@@ -48,26 +46,36 @@ class TelepagosWebhookService
|
||||
|
||||
if (! $cuit) {
|
||||
Log::warning("Telepagos webhook: CUIT not found for Transferencia cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$dni = substr($cuit, 2, -1);
|
||||
|
||||
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
|
||||
->where('transfer_payer_dni', $dni)
|
||||
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
||||
->where('transfer_payer_dni', $dni)
|
||||
->whereIn('status', [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
])
|
||||
->where('payment_method', 'transfer')
|
||||
->where('total', $amount)
|
||||
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
])
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if (! $compra) {
|
||||
Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
|
||||
if (! $qrOrderId) {
|
||||
Log::warning("Telepagos webhook: qr_order_id not found for QR cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,6 +83,7 @@ class TelepagosWebhookService
|
||||
|
||||
if (! $telepagosQr) {
|
||||
Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,11 +91,16 @@ class TelepagosWebhookService
|
||||
|
||||
if (! $compra) {
|
||||
Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($compra->status !== Purchase::STATUS_PENDING_PAYMENT) {
|
||||
if (! in_array($compra->status, [
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
], true)) {
|
||||
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,10 +108,12 @@ class TelepagosWebhookService
|
||||
|
||||
if ($amount !== $totalAmount) {
|
||||
Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}");
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Log::warning("Telepagos webhook: Unknown operation_id {$operationId} for cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,7 +130,7 @@ class TelepagosWebhookService
|
||||
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
|
||||
];
|
||||
|
||||
\Illuminate\Support\Facades\DB::transaction(function () use ($compra, $paymentData) {
|
||||
DB::transaction(function () use ($compra, $paymentData) {
|
||||
TelepagosPayment::create($paymentData);
|
||||
$this->checkoutService->confirmPurchase($compra);
|
||||
$compra->markAsPaid();
|
||||
@@ -122,7 +138,7 @@ class TelepagosWebhookService
|
||||
|
||||
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");
|
||||
} catch (Exception $e) {
|
||||
Log::error("Telepagos webhook error: " . $e->getMessage());
|
||||
Log::error('Telepagos webhook error: '.$e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ class MailTestService
|
||||
);
|
||||
|
||||
return [
|
||||
'message' => 'Correo de prueba enviado correctamente.',
|
||||
'code' => 'mail.test_sent',
|
||||
'message' => __('api.mail.test_sent'),
|
||||
'recipient' => $recipient,
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'mailer' => $mailService->mailerName(),
|
||||
|
||||
@@ -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;
|
||||
@@ -48,7 +49,7 @@ class Menu extends Model
|
||||
&& empty($menu->static_content_schema)
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'static_content_schema' => 'El schema es obligatorio para los menús estáticos.',
|
||||
'static_content_schema' => __('api.menu.schema_required'),
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
31
app/Domains/Menu/Models/MenuRole.php
Normal file
31
app/Domains/Menu/Models/MenuRole.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ class StoreTenantMenuRequest extends FormRequest
|
||||
|
||||
if (! $this->menuModel) {
|
||||
throw ValidationException::withMessages([
|
||||
'menu_code' => 'El menú indicado no existe.',
|
||||
'menu_code' => __('api.menu.not_found'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ class PurchaseController extends Controller
|
||||
{
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
|
||||
$compra->loadMissing('items');
|
||||
$compra->loadMissing('items')->loadCount('tickets');
|
||||
$compra->items->load('imageAttachment');
|
||||
|
||||
return PurchaseResource::make($compra);
|
||||
@@ -135,7 +135,7 @@ class PurchaseController extends Controller
|
||||
|
||||
if ($updated === 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => 'The purchase is no longer available for payment.',
|
||||
'purchase' => __('api.purchase.not_available_for_payment'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -159,8 +159,14 @@ class PurchaseController extends Controller
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Unable to retrieve TelePagos account information.', [
|
||||
'purchase_id' => $compra->id,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Error getting account info: '.$e->getMessage(),
|
||||
'code' => 'integration.account_info_failed',
|
||||
'message' => __('api.integration.account_info_failed'),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
@@ -182,8 +188,14 @@ class PurchaseController extends Controller
|
||||
'qr_code' => $qrResponse['qr_code'] ?? '',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Unable to generate TelePagos QR code.', [
|
||||
'purchase_id' => $compra->id,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Error generating QR: '.$e->getMessage(),
|
||||
'code' => 'integration.qr_generation_failed',
|
||||
'message' => __('api.integration.qr_generation_failed'),
|
||||
], 500);
|
||||
}
|
||||
|
||||
@@ -197,7 +209,8 @@ class PurchaseController extends Controller
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Invalid payment method.',
|
||||
'code' => 'integration.invalid_payment_method',
|
||||
'message' => __('api.integration.invalid_payment_method'),
|
||||
], 400);
|
||||
}
|
||||
|
||||
@@ -210,6 +223,19 @@ class PurchaseController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function submitForReview(
|
||||
Request $request,
|
||||
Tenant $tenant,
|
||||
Purchase $compra,
|
||||
CheckoutService $checkoutService,
|
||||
): PurchaseResource {
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
|
||||
return PurchaseResource::make(
|
||||
$checkoutService->submitForReview($compra),
|
||||
);
|
||||
}
|
||||
|
||||
public function cancel(Request $request, Tenant $tenant, Purchase $compra, CheckoutService $checkoutService): PurchaseResource
|
||||
{
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
@@ -222,7 +248,7 @@ class PurchaseController extends Controller
|
||||
protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase
|
||||
{
|
||||
if ($purchase->tenant_codigo !== $tenant->codigo || $purchase->user_id !== $userId) {
|
||||
throw new NotFoundHttpException('Purchase not found for tenant.');
|
||||
throw new NotFoundHttpException(__('api.errors.not_found'));
|
||||
}
|
||||
|
||||
return $purchase;
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Purchase\Events\PurchasePaid;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -36,6 +37,8 @@ class Purchase extends Model
|
||||
|
||||
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
||||
|
||||
public const STATUS_IN_REVIEW = 'in_review';
|
||||
|
||||
public const STATUS_PAID = 'paid';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
@@ -88,6 +91,14 @@ class Purchase extends Model
|
||||
return $this->hasMany(PurchaseItem::class, 'compra_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Ticket, $this>
|
||||
*/
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'source_purchase_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<TelepagosQr, $this>
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,9 @@ class PurchaseResource extends JsonResource
|
||||
$items = $this->resource->relationLoaded('items')
|
||||
? $this->resource->getRelation('items')
|
||||
: collect();
|
||||
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
|
||||
? (int) $this->resource->getAttribute('tickets_count')
|
||||
: null;
|
||||
|
||||
$subtotal = $items->isNotEmpty()
|
||||
? $items->reduce(
|
||||
@@ -51,6 +54,8 @@ class PurchaseResource extends JsonResource
|
||||
'email' => $this->email,
|
||||
'items_source' => $items->isNotEmpty() ? 'purchase' : null,
|
||||
'items' => PurchaseItemResource::collection($items),
|
||||
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
|
||||
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
|
||||
'subtotal' => $this->formatMoney($subtotal),
|
||||
'total' => $this->formatMoney($total),
|
||||
];
|
||||
|
||||
@@ -41,7 +41,7 @@ class CheckoutService
|
||||
|
||||
if ($cartId === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => 'A cart or direct item is required.',
|
||||
'cart_id' => __('api.purchase.source_required'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -64,12 +64,13 @@ class CheckoutService
|
||||
|
||||
if ($purchase->payment_method === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'payment_method' => 'The purchase payment method must be selected before finalizing.',
|
||||
'payment_method' => __('api.purchase.payment_method_required'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($purchase->status, [
|
||||
Purchase::STATUS_PAID,
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
Purchase::STATUS_CANCELLED,
|
||||
Purchase::STATUS_REJECTED,
|
||||
Purchase::STATUS_EXPIRED,
|
||||
@@ -86,6 +87,39 @@ class CheckoutService
|
||||
});
|
||||
}
|
||||
|
||||
public function submitForReview(Purchase $purchase): Purchase
|
||||
{
|
||||
return DB::transaction(function () use ($purchase): Purchase {
|
||||
/** @var Purchase $purchase */
|
||||
$purchase = Purchase::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($purchase->getKey());
|
||||
|
||||
if (in_array($purchase->status, [
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
Purchase::STATUS_PAID,
|
||||
], true)) {
|
||||
return $this->loadPurchase($purchase);
|
||||
}
|
||||
|
||||
if (
|
||||
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|
||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => __('api.purchase.not_available_for_review'),
|
||||
]);
|
||||
}
|
||||
|
||||
$purchase->update([
|
||||
'status' => Purchase::STATUS_IN_REVIEW,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
|
||||
return $this->loadPurchase($purchase);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $customerData
|
||||
*/
|
||||
@@ -98,11 +132,14 @@ class CheckoutService
|
||||
->findOrFail($purchase->getKey());
|
||||
|
||||
if (
|
||||
$purchase->status !== Purchase::STATUS_CREATED
|
||||
! in_array($purchase->status, [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)
|
||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => 'The purchase is no longer editable.',
|
||||
'purchase' => __('api.purchase.not_editable'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -128,7 +165,7 @@ class CheckoutService
|
||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => 'The purchase is no longer editable.',
|
||||
'purchase' => __('api.purchase.not_editable'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -144,7 +181,7 @@ class CheckoutService
|
||||
|
||||
if ($purchaseItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) {
|
||||
throw ValidationException::withMessages([
|
||||
'item' => 'The purchase item is no longer editable.',
|
||||
'item' => __('api.purchase.item_not_editable'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -162,7 +199,7 @@ class CheckoutService
|
||||
}
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'quantity' => 'No hay suficiente stock disponible.',
|
||||
'quantity' => __('api.purchase.insufficient_stock'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -197,7 +234,7 @@ class CheckoutService
|
||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => 'The purchase is no longer editable.',
|
||||
'purchase' => __('api.purchase.not_editable'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -233,7 +270,7 @@ class CheckoutService
|
||||
Purchase::STATUS_EXPIRED,
|
||||
], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => 'A cancelled, rejected or expired purchase cannot be confirmed.',
|
||||
'purchase' => __('api.purchase.cannot_confirm'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -249,7 +286,7 @@ class CheckoutService
|
||||
$this->catalogInventoryService->commit($selection, (int) $item->cantidad);
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => 'The purchase has an inconsistent stock reservation.',
|
||||
'items' => __('api.purchase.inconsistent_reservation'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -306,7 +343,7 @@ class CheckoutService
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => 'A paid purchase cannot be cancelled.',
|
||||
'purchase' => __('api.purchase.paid_cannot_cancel'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -339,7 +376,7 @@ class CheckoutService
|
||||
$this->catalogInventoryService->release($selection, (int) $item->cantidad);
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => 'The purchase has an inconsistent stock reservation.',
|
||||
'items' => __('api.purchase.inconsistent_reservation'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -375,7 +412,7 @@ class CheckoutService
|
||||
|
||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||
throw ValidationException::withMessages([
|
||||
'direct_item.cantidad' => "Stock insuficiente. Maximo disponible: {$availableQuantity}.",
|
||||
'direct_item.cantidad' => __('api.purchase.direct_item_max_stock', ['max' => $availableQuantity]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -383,7 +420,7 @@ class CheckoutService
|
||||
$this->catalogInventoryService->reserve($selection, $quantity);
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'direct_item.cantidad' => 'No hay suficiente stock disponible.',
|
||||
'direct_item.cantidad' => __('api.purchase.insufficient_stock'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -416,7 +453,7 @@ class CheckoutService
|
||||
|
||||
if ($cartItems->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => 'The selected cart does not contain items.',
|
||||
'cart_id' => __('api.purchase.empty_cart'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -596,13 +633,13 @@ class CheckoutService
|
||||
foreach ($cartItems as $item) {
|
||||
if ($item->selectedItem() === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => 'One or more catalog items could not be loaded.',
|
||||
'cart_id' => __('api.purchase.catalog_item_missing'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($item->catalogItem?->tenant_code !== $tenant->codigo) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => 'One or more catalog items do not belong to the tenant.',
|
||||
'cart_id' => __('api.purchase.catalog_item_wrong_tenant'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -621,7 +658,7 @@ class CheckoutService
|
||||
|
||||
if ($cart->status !== 'active') {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => 'The selected cart is no longer active.',
|
||||
'cart_id' => __('api.purchase.inactive_cart'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -682,13 +719,13 @@ class CheckoutService
|
||||
if ($catalogItem->isBundle()) {
|
||||
if ($variantId !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'direct_item.variant_id' => 'Un bundle no admite una variante.',
|
||||
'direct_item.variant_id' => __('api.cart.bundle_variant_forbidden'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $catalogItem->bundleComponents()->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'direct_item.catalog_item_id' => 'El bundle no tiene componentes.',
|
||||
'direct_item.catalog_item_id' => __('api.cart.empty_bundle'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -698,7 +735,7 @@ class CheckoutService
|
||||
if ($variantId === null) {
|
||||
if ($catalogItem->inventory_id === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'direct_item.variant_id' => 'Debe seleccionar una variante para este item.',
|
||||
'direct_item.variant_id' => __('api.cart.variant_required'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,5 +12,6 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func
|
||||
Route::patch('compras/{compra}/customer-data', [PurchaseController::class, 'updateCustomerData']);
|
||||
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
|
||||
Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']);
|
||||
Route::post('compras/{compra}/review', [PurchaseController::class, 'submitForReview']);
|
||||
Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?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\UpdateWebsiteExtrasRequest;
|
||||
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 update(UpdateWebsiteExtrasRequest $request): WebsiteExtrasResource
|
||||
{
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
$this->websiteExtraService->replaceForTenant(
|
||||
$tenant,
|
||||
$request->validated('extras', [])
|
||||
);
|
||||
|
||||
return WebsiteExtrasResource::make(
|
||||
$this->loadTenant($request->user())
|
||||
);
|
||||
}
|
||||
|
||||
private function loadTenant(User $user): Tenant
|
||||
{
|
||||
$tenant = $user->tenant()->firstOrFail();
|
||||
|
||||
return $this->tenantInformationService->load($tenant, [
|
||||
'websiteType.extras',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,23 +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'])
|
||||
->where('dominio', $dominio)
|
||||
->firstOrFail()
|
||||
$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'),
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Menu\Models\TenantMenu;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
@@ -27,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',
|
||||
@@ -56,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',
|
||||
@@ -81,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
|
||||
@@ -109,6 +91,14 @@ class Tenant extends Model
|
||||
return $this->hasMany(CatalogItem::class, 'tenant_code', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Category, $this>
|
||||
*/
|
||||
public function categories(): HasMany
|
||||
{
|
||||
return $this->hasMany(Category::class, 'tenant_code', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<SocialMedia, $this>
|
||||
*/
|
||||
@@ -127,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(
|
||||
|
||||
64
app/Domains/Tenant/Models/WebsiteExtra.php
Normal file
64
app/Domains/Tenant/Models/WebsiteExtra.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?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',
|
||||
])]
|
||||
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',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
35
app/Domains/Tenant/Models/WebsiteType.php
Normal file
35
app/Domains/Tenant/Models/WebsiteType.php
Normal 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');
|
||||
}
|
||||
}
|
||||
50
app/Domains/Tenant/Models/WebsiteTypeExtra.php
Normal file
50
app/Domains/Tenant/Models/WebsiteTypeExtra.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?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',
|
||||
'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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Requests\AdminApp;
|
||||
|
||||
use App\Domains\Tenant\Services\WebsiteExtraService;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateWebsiteExtrasRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return app(WebsiteExtraService::class)->requestRules(
|
||||
$this->user()?->tenant?->website_type_code
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?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->nombre
|
||||
);
|
||||
|
||||
return [
|
||||
'website_type' => $this->websiteType ? [
|
||||
'codigo' => $this->websiteType->codigo,
|
||||
'nombre' => $this->websiteType->nombre,
|
||||
] : null,
|
||||
'definitions' => $this->websiteType?->extras
|
||||
->mapWithKeys(fn ($definition) => [
|
||||
$definition->nombre => [
|
||||
'descripcion' => $definition->descripcion,
|
||||
'is_required' => $definition->is_required,
|
||||
'request_rules' => $definition->config_schema['request_rules'] ?? [],
|
||||
],
|
||||
]) ?? [],
|
||||
'extras' => $websiteExtras->mapWithKeys(fn ($extra) => [
|
||||
$extra->websiteTypeExtra->nombre => $this->formatConfig(
|
||||
$extra->resolvedConfig(),
|
||||
fn (Attachment $attachment): string => $attachment->key
|
||||
),
|
||||
]),
|
||||
'resolved_extras' => $websiteExtras->mapWithKeys(fn ($extra) => [
|
||||
$extra->websiteTypeExtra->nombre => $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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -18,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,
|
||||
@@ -35,20 +31,28 @@ 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->mapWithKeys(fn ($extra) => [
|
||||
$extra->websiteTypeExtra->nombre => $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
|
||||
@@ -64,9 +68,60 @@ class TenantResource extends JsonResource
|
||||
'menues',
|
||||
fn () => $this->menuTree($this->menues)
|
||||
),
|
||||
'categories' => $this->whenLoaded(
|
||||
'categories',
|
||||
fn () => $this->categoryTree($this->categories)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatExtraConfig(mixed $value): mixed
|
||||
{
|
||||
if ($value instanceof Attachment) {
|
||||
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>>
|
||||
*/
|
||||
private function categoryTree(Collection $categories): Collection
|
||||
{
|
||||
$categoryIds = $categories->pluck('id')->flip();
|
||||
$childrenByParent = $categories
|
||||
->filter(fn (Category $category) => $category->categoria_id !== null
|
||||
&& $categoryIds->has($category->categoria_id))
|
||||
->groupBy('categoria_id');
|
||||
|
||||
$formatCategory = function (Category $category) use (&$formatCategory, $childrenByParent): array {
|
||||
return [
|
||||
'id' => $category->id,
|
||||
'nombre' => $category->nombre,
|
||||
'subcategories' => $childrenByParent
|
||||
->get($category->id, collect())
|
||||
->map($formatCategory)
|
||||
->values(),
|
||||
];
|
||||
};
|
||||
|
||||
return $categories
|
||||
->filter(fn (Category $category) => $category->categoria_id === null
|
||||
|| ! $categoryIds->has($category->categoria_id))
|
||||
->map($formatCategory)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Menu> $menus
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
|
||||
177
app/Domains/Tenant/Services/TenantInformationService.php
Normal file
177
app/Domains/Tenant/Services/TenantInformationService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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}" => [
|
||||
'La imagen indicada no existe o no es un attachment de tipo imagen.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
return $this->attachmentService->store($image, 'tenants/main-carousel');
|
||||
}
|
||||
}
|
||||
|
||||
321
app/Domains/Tenant/Services/WebsiteExtraService.php
Normal file
321
app/Domains/Tenant/Services/WebsiteExtraService.php
Normal file
@@ -0,0 +1,321 @@
|
||||
<?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\WebsiteType;
|
||||
use App\Domains\Tenant\Models\WebsiteTypeExtra;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
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);
|
||||
$allowedNames = $definitions->pluck('nombre')->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 ($allowedNames): void {
|
||||
if (! is_array($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$unknownNames = array_diff(array_keys($value), $allowedNames);
|
||||
|
||||
if ($unknownNames !== []) {
|
||||
$fail(
|
||||
'The '.$attribute.' field contains extras not supported by the website type: '
|
||||
.implode(', ', $unknownNames).'.'
|
||||
);
|
||||
}
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
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->nombre}"] = $rootRules;
|
||||
|
||||
foreach ($schemaRules as $path => $pathRules) {
|
||||
if ($path === '$') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rules[$this->requestAttribute($definition->nombre, $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('nombre');
|
||||
|
||||
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."],
|
||||
]);
|
||||
}
|
||||
|
||||
$transformedConfig = $this->applyTransforms($tenant, $definition, $config);
|
||||
$this->validateDatabaseConfig($definition, $transformedConfig);
|
||||
|
||||
$tenant->websiteExtras()->create([
|
||||
'website_type_extra_id' => $definition->id,
|
||||
'config' => $transformedConfig,
|
||||
]);
|
||||
}
|
||||
|
||||
$tenant->unsetRelation('websiteExtras');
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all configured extras for a tenant.
|
||||
*
|
||||
* @param array<string, mixed> $extras
|
||||
*/
|
||||
public function replaceForTenant(Tenant $tenant, array $extras): void
|
||||
{
|
||||
DB::transaction(function () use ($tenant, $extras): void {
|
||||
$tenant->websiteExtras()->delete();
|
||||
$this->createForTenant($tenant, $extras);
|
||||
});
|
||||
|
||||
$tenant->unsetRelation('websiteExtras');
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 $extraName, string $path): string
|
||||
{
|
||||
if ($path === '$') {
|
||||
return "extras.{$extraName}";
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '$.')) {
|
||||
$path = substr($path, 2);
|
||||
}
|
||||
|
||||
return "extras.{$extraName}.{$path}";
|
||||
}
|
||||
|
||||
private function applyTransforms(
|
||||
Tenant $tenant,
|
||||
WebsiteTypeExtra $definition,
|
||||
mixed $config
|
||||
): 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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
): mixed {
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($transform['handler'] ?? null) !== 'attachment') {
|
||||
throw new InvalidArgumentException(
|
||||
"Unsupported transform handler for {$definition->nombre}: ".($transform['handler'] ?? 'null')
|
||||
);
|
||||
}
|
||||
|
||||
if (is_string($value) && Str::isUuid($value)) {
|
||||
$attachment = Attachment::query()->where('key', $value)->first();
|
||||
|
||||
if (! $attachment) {
|
||||
throw ValidationException::withMessages([
|
||||
$this->requestAttribute($definition->nombre, $path) => [
|
||||
'The selected attachment does not exist.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$attachment = $this->attachmentService->store(
|
||||
$value,
|
||||
"tenants/{$tenant->codigo}/extras/{$definition->nombre}"
|
||||
);
|
||||
}
|
||||
|
||||
$expectedType = $transform['attachment_type'] ?? null;
|
||||
|
||||
if (
|
||||
is_string($expectedType)
|
||||
&& $attachment->type instanceof AttachmentType
|
||||
&& $attachment->type->value !== $expectedType
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
$this->requestAttribute($definition->nombre, $path) => [
|
||||
"The attachment must be of type {$expectedType}.",
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return $attachment->id;
|
||||
}
|
||||
|
||||
private function validateDatabaseConfig(
|
||||
WebsiteTypeExtra $definition,
|
||||
mixed $config
|
||||
): void {
|
||||
$schemaRules = $definition->config_schema['database_rules'] ?? [];
|
||||
$rules = [];
|
||||
|
||||
foreach ($schemaRules as $path => $pathRules) {
|
||||
$attribute = $path === '$'
|
||||
? 'config'
|
||||
: 'config.'.ltrim(str_starts_with($path, '$.') ? substr($path, 2) : $path, '.');
|
||||
$rules[$attribute] = $this->compileRules($pathRules);
|
||||
}
|
||||
|
||||
$validator = Validator::make(['config' => $config], $rules);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$messages = [];
|
||||
|
||||
foreach ($validator->errors()->toArray() as $attribute => $errors) {
|
||||
$suffix = $attribute === 'config' ? '' : substr($attribute, strlen('config'));
|
||||
$messages["extras.{$definition->nombre}{$suffix}"] = $errors;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages($messages);
|
||||
}
|
||||
}
|
||||
}
|
||||
13
app/Domains/Tenant/routes/adminapp.php
Normal file
13
app/Domains/Tenant/routes/adminapp.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Tenant\Controllers\AdminApp\WebsiteExtraController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('v1/adminapp/tenant')
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||
->group(function (): void {
|
||||
Route::get('website-extras', [WebsiteExtraController::class, 'show'])
|
||||
->name('adminapp.tenant.website-extras.show');
|
||||
Route::put('website-extras', [WebsiteExtraController::class, 'update'])
|
||||
->name('adminapp.tenant.website-extras.update');
|
||||
});
|
||||
@@ -8,3 +8,5 @@ Route::get('tenants/bootstrap/{dominio}', BootstrapTenantController::class)
|
||||
->where('dominio', '.*');
|
||||
|
||||
Route::apiResource('tenants', TenantController::class);
|
||||
|
||||
require __DIR__.'/adminapp.php';
|
||||
|
||||
@@ -3,14 +3,20 @@
|
||||
namespace App\Domains\Ticket\Controllers;
|
||||
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use App\Domains\Ticket\Requests\DownloadTicketsPdfRequest;
|
||||
use App\Domains\Ticket\Resources\TicketResource;
|
||||
use App\Domains\Ticket\Services\TicketPdfService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class TicketController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TicketPdfService $ticketPdfService) {}
|
||||
|
||||
public function index(Request $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
$tickets = Ticket::query()
|
||||
@@ -21,4 +27,21 @@ class TicketController extends Controller
|
||||
|
||||
return TicketResource::collection($tickets)->response();
|
||||
}
|
||||
|
||||
public function downloadPdf(DownloadTicketsPdfRequest $request, Tenant $tenant): Response
|
||||
{
|
||||
$ticketIds = $request->validated('ticket_ids');
|
||||
$tickets = Ticket::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('user_id', $request->user()->getKey())
|
||||
->whereIn('id', $ticketIds)
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
if ($tickets->count() !== count($ticketIds)) {
|
||||
throw new TicketNotAvailableException(__('api.ticket.not_available'));
|
||||
}
|
||||
|
||||
return $this->ticketPdfService->download($tenant, $tickets);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,22 +11,22 @@ class TicketGenerationException extends RuntimeException
|
||||
{
|
||||
public static function invalidQuantity(): self
|
||||
{
|
||||
return new self('La cantidad de tickets a generar debe ser mayor a cero.');
|
||||
return new self(__('api.ticket.positive_quantity'));
|
||||
}
|
||||
|
||||
public static function emptyBundle(CatalogItem $bundle): self
|
||||
{
|
||||
return new self("El bundle {$bundle->id} no tiene componentes.");
|
||||
return new self(__('api.ticket.empty_bundle', ['bundle' => $bundle->id]));
|
||||
}
|
||||
|
||||
public static function ticketsDisabled(CatalogItem $catalogItem): self
|
||||
{
|
||||
return new self("El producto {$catalogItem->id} no tiene tickets habilitados.");
|
||||
return new self(__('api.ticket.disabled', ['product' => $catalogItem->id]));
|
||||
}
|
||||
|
||||
public static function maximumUseDateReached(CatalogItem $catalogItem): self
|
||||
{
|
||||
return new self("El producto {$catalogItem->id} alcanzó su fecha máxima de uso.");
|
||||
return new self(__('api.ticket.expired', ['product' => $catalogItem->id]));
|
||||
}
|
||||
|
||||
public static function variantNotFound(
|
||||
@@ -34,17 +34,20 @@ class TicketGenerationException extends RuntimeException
|
||||
int $variantId,
|
||||
): self {
|
||||
return new self(
|
||||
"La variante {$variantId} no pertenece al producto {$catalogItem->id}.",
|
||||
__('api.ticket.invalid_variant', [
|
||||
'variant' => $variantId,
|
||||
'product' => $catalogItem->id,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public static function purchaseWithoutUser(Purchase $purchase): self
|
||||
{
|
||||
return new self("La compra {$purchase->id} no tiene un usuario asociado.");
|
||||
return new self(__('api.ticket.purchase_without_user', ['purchase' => $purchase->id]));
|
||||
}
|
||||
|
||||
public static function catalogItemNotFound(PurchaseItem $purchaseItem): self
|
||||
{
|
||||
return new self("No se encontró el producto de la línea de compra {$purchaseItem->id}.");
|
||||
return new self(__('api.ticket.product_not_found', ['purchase_item' => $purchaseItem->id]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticket\Exceptions;
|
||||
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class TicketNotAvailableException extends NotFoundHttpException {}
|
||||
@@ -45,6 +45,7 @@ class GenerateTicketsForPaidPurchase
|
||||
$user,
|
||||
$purchaseItem->cantidad,
|
||||
$purchaseItem->source_variant_id,
|
||||
$purchase->getKey(),
|
||||
);
|
||||
|
||||
array_push($ticketIds, ...$generatedTickets->pluck('id')->all());
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Ticket\Models;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
'ticket',
|
||||
'name',
|
||||
'description',
|
||||
'source_purchase_id',
|
||||
'source_catalog_item_id',
|
||||
'source_variant_id',
|
||||
'starts_at',
|
||||
@@ -38,6 +40,7 @@ class Ticket extends Model
|
||||
return [
|
||||
'source_catalog_item_id' => 'integer',
|
||||
'source_variant_id' => 'integer',
|
||||
'source_purchase_id' => 'integer',
|
||||
'starts_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'used_at' => 'datetime',
|
||||
@@ -57,6 +60,12 @@ class Ticket extends Model
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Purchase, $this> */
|
||||
public function sourcePurchase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Purchase::class, 'source_purchase_id');
|
||||
}
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
$now = now();
|
||||
|
||||
22
app/Domains/Ticket/Requests/DownloadTicketsPdfRequest.php
Normal file
22
app/Domains/Ticket/Requests/DownloadTicketsPdfRequest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticket\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class DownloadTicketsPdfRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, array<int, string>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'ticket_ids' => ['required', 'array', 'min:1', 'max:25'],
|
||||
'ticket_ids.*' => ['required', 'integer', 'distinct'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,13 @@ class TicketGeneratorService
|
||||
User $user,
|
||||
int $quantity = 1,
|
||||
?int $sourceVariantId = null,
|
||||
?int $sourcePurchaseId = null,
|
||||
): Collection {
|
||||
if ($quantity < 1) {
|
||||
throw TicketGenerationException::invalidQuantity();
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId): Collection {
|
||||
return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId, $sourcePurchaseId): Collection {
|
||||
$targets = $this->resolveTargets(
|
||||
$catalogItem,
|
||||
$quantity,
|
||||
@@ -36,6 +37,7 @@ class TicketGeneratorService
|
||||
return $targets->map(function (array $target) use (
|
||||
$catalogItem,
|
||||
$sourceVariantId,
|
||||
$sourcePurchaseId,
|
||||
$user,
|
||||
): Ticket {
|
||||
$item = $target['catalog_item'];
|
||||
@@ -46,6 +48,7 @@ class TicketGeneratorService
|
||||
'ticket' => (string) Str::uuid(),
|
||||
'name' => $item->nombre,
|
||||
'description' => (string) ($item->descripcion ?? ''),
|
||||
'source_purchase_id' => $sourcePurchaseId,
|
||||
'source_catalog_item_id' => $catalogItem->getKey(),
|
||||
'source_variant_id' => $sourceVariantId,
|
||||
'starts_at' => $selectedItem->getMinimumUseDate(),
|
||||
|
||||
88
app/Domains/Ticket/Services/TicketPdfService.php
Normal file
88
app/Domains/Ticket/Services/TicketPdfService.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticket\Services;
|
||||
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Endroid\QrCode\ErrorCorrectionLevel;
|
||||
use Endroid\QrCode\QrCode;
|
||||
use Endroid\QrCode\Writer\PngWriter;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
class TicketPdfService
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, Ticket> $tickets
|
||||
*/
|
||||
public function download(Tenant $tenant, Collection $tickets): Response
|
||||
{
|
||||
$tenant->loadMissing('headerLogo');
|
||||
$primaryColor = $this->color($tenant->primary_color, '#009933');
|
||||
$headerBackgroundColor = $this->color($tenant->header_bg_color, $primaryColor);
|
||||
|
||||
$pdf = Pdf::loadView('pdf.tickets', [
|
||||
'tenant' => $tenant,
|
||||
'tickets' => $tickets,
|
||||
'logoDataUri' => $this->logoDataUri($tenant),
|
||||
'primaryColor' => $primaryColor,
|
||||
'headerBackgroundColor' => $headerBackgroundColor,
|
||||
'headerTextColor' => $this->contrastingTextColor($headerBackgroundColor),
|
||||
'qrCodes' => $tickets->mapWithKeys(
|
||||
fn (Ticket $ticket): array => [$ticket->id => $this->qrCodeDataUri($ticket->ticket)]
|
||||
),
|
||||
])->setPaper('a4');
|
||||
|
||||
$ticketIds = $tickets->pluck('id')->implode('_');
|
||||
|
||||
return $pdf->download("tickets_{$ticketIds}.pdf");
|
||||
}
|
||||
|
||||
private function qrCodeDataUri(string $value): string
|
||||
{
|
||||
$qrCode = new QrCode(
|
||||
data: $value,
|
||||
errorCorrectionLevel: ErrorCorrectionLevel::Medium,
|
||||
size: 700,
|
||||
margin: 10,
|
||||
);
|
||||
|
||||
return (new PngWriter)->write($qrCode)->getDataUri();
|
||||
}
|
||||
|
||||
private function logoDataUri(Tenant $tenant): ?string
|
||||
{
|
||||
$logo = $tenant->headerLogo;
|
||||
if ($logo === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$contents = Storage::disk('s3')->get($logo->path);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'data:'.($logo->mime_type ?: 'image/png').';base64,'.base64_encode($contents);
|
||||
}
|
||||
|
||||
private function color(?string $color, string $fallback): string
|
||||
{
|
||||
return is_string($color) && preg_match('/^#[0-9A-Fa-f]{6}$/', $color)
|
||||
? $color
|
||||
: $fallback;
|
||||
}
|
||||
|
||||
private function contrastingTextColor(string $backgroundColor): string
|
||||
{
|
||||
$red = hexdec(substr($backgroundColor, 1, 2));
|
||||
$green = hexdec(substr($backgroundColor, 3, 2));
|
||||
$blue = hexdec(substr($backgroundColor, 5, 2));
|
||||
$luminance = ($red * 299 + $green * 587 + $blue * 114) / 1000;
|
||||
|
||||
return $luminance > 160 ? '#17211b' : '#ffffff';
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,5 @@ Route::prefix('tenants/{tenant:codigo}')
|
||||
->middleware('auth:sanctum')
|
||||
->group(function (): void {
|
||||
Route::get('tickets', [TicketController::class, 'index']);
|
||||
Route::post('tickets/pdf', [TicketController::class, 'downloadPdf']);
|
||||
});
|
||||
|
||||
30
app/Http/Middleware/EnsureAdminAppTenant.php
Normal file
30
app/Http/Middleware/EnsureAdminAppTenant.php
Normal 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);
|
||||
}
|
||||
}
|
||||
33
app/Http/Middleware/SetApiLocale.php
Normal file
33
app/Http/Middleware/SetApiLocale.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetApiLocale
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->is('api/*')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$supportedLocales = config('app.supported_locales', ['es', 'en']);
|
||||
$acceptLanguage = $request->header('Accept-Language');
|
||||
$locale = is_string($acceptLanguage) && $acceptLanguage !== ''
|
||||
? $request->getPreferredLanguage($supportedLocales)
|
||||
: null;
|
||||
$locale ??= config('app.locale', 'es');
|
||||
|
||||
App::setLocale($locale);
|
||||
|
||||
$response = $next($request);
|
||||
$response->headers->set('Content-Language', $locale);
|
||||
$response->setVary('Accept-Language', false);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,11 @@ use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
|
||||
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
||||
use App\Domains\Purchase\Events\PurchasePaid;
|
||||
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -36,6 +39,25 @@ class AppServiceProvider extends ServiceProvider
|
||||
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
||||
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);
|
||||
|
||||
RateLimiter::for('login', function (Request $request): array {
|
||||
$normalizedEmail = mb_strtolower(trim((string) $request->input('email')));
|
||||
$emailFingerprint = hash_hmac(
|
||||
'sha256',
|
||||
$normalizedEmail,
|
||||
(string) config('app.key'),
|
||||
);
|
||||
$ipAddress = $request->ip() ?? 'unknown';
|
||||
|
||||
return [
|
||||
Limit::perMinute(
|
||||
max(1, (int) config('login-security.rate_limit_per_minute'))
|
||||
)->by("login:identity:{$emailFingerprint}:{$ipAddress}"),
|
||||
Limit::perMinute(
|
||||
max(1, (int) config('login-security.ip_rate_limit_per_minute'))
|
||||
)->by("login:ip:{$ipAddress}"),
|
||||
];
|
||||
});
|
||||
|
||||
Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
|
||||
/** @var Builder $this */
|
||||
$perPage = (int) request()->query('per_page', $defaultPerPage);
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -13,12 +22,92 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->alias([
|
||||
'adminapp.tenant' => EnsureAdminAppTenant::class,
|
||||
]);
|
||||
$middleware->encryptCookies(except: [
|
||||
'guest_token',
|
||||
]);
|
||||
$middleware->prepend(SetApiLocale::class);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*'),
|
||||
);
|
||||
$exceptions->render(function (AuthenticationException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'auth.unauthenticated',
|
||||
'message' => __('api.auth.unauthenticated'),
|
||||
], 401);
|
||||
});
|
||||
$exceptions->render(function (AccountLockedException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$retryAfter = $exception->retryAfterSeconds();
|
||||
|
||||
return response()->json([
|
||||
'code' => 'auth.account_locked',
|
||||
'message' => __('api.auth.account_locked'),
|
||||
'retry_after' => $retryAfter,
|
||||
'locked_until' => $exception->lockedUntil->toIso8601String(),
|
||||
], 429, [
|
||||
'Retry-After' => (string) $retryAfter,
|
||||
]);
|
||||
});
|
||||
$exceptions->render(function (AuthorizationException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'errors.forbidden',
|
||||
'message' => __('api.errors.forbidden'),
|
||||
], 403);
|
||||
});
|
||||
$exceptions->render(function (ModelNotFoundException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'errors.not_found',
|
||||
'message' => __('api.errors.not_found'),
|
||||
], 404);
|
||||
});
|
||||
$exceptions->render(function (TicketNotAvailableException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'ticket.not_available',
|
||||
'message' => __('api.ticket.not_available'),
|
||||
], 404);
|
||||
});
|
||||
$exceptions->render(function (NotFoundHttpException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'errors.not_found',
|
||||
'message' => __('api.errors.not_found'),
|
||||
], 404);
|
||||
});
|
||||
$exceptions->render(function (MethodNotAllowedHttpException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'errors.method_not_allowed',
|
||||
'message' => __('api.errors.method_not_allowed'),
|
||||
], 405, $exception->getHeaders());
|
||||
});
|
||||
})->create();
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"endroid/qr-code": "^6.1",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/socialite": "^5.29",
|
||||
|
||||
701
composer.lock
generated
701
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "930f49a939d8b56a337d2fca9c48ad86",
|
||||
"content-hash": "aa7e98d017dd610946c62579b20c501f",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
@@ -157,6 +157,138 @@
|
||||
},
|
||||
"time": "2026-07-23T18:06:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
"version": "v3.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Bacon/BaconQrCode.git",
|
||||
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
|
||||
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dasprid/enum": "^1.0.3",
|
||||
"ext-iconv": "*",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phly/keep-a-changelog": "^2.12",
|
||||
"phpunit/phpunit": "^10.5.11 || ^11.0.4",
|
||||
"spatie/phpunit-snapshot-assertions": "^5.1.5",
|
||||
"spatie/pixelmatch-php": "^1.2.0",
|
||||
"squizlabs/php_codesniffer": "^3.9"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-imagick": "to generate QR code images"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"BaconQrCode\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "BaconQrCode is a QR code generator for PHP.",
|
||||
"homepage": "https://github.com/Bacon/BaconQrCode",
|
||||
"support": {
|
||||
"issues": "https://github.com/Bacon/BaconQrCode/issues",
|
||||
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1"
|
||||
},
|
||||
"time": "2026-04-05T21:06:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "barryvdh/laravel-dompdf",
|
||||
"version": "v3.1.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/barryvdh/laravel-dompdf.git",
|
||||
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc",
|
||||
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dompdf/dompdf": "^3.0",
|
||||
"illuminate/support": "^9|^10|^11|^12|^13.0",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"larastan/larastan": "^2.7|^3.0",
|
||||
"orchestra/testbench": "^7|^8|^9.16|^10|^11.0",
|
||||
"phpro/grumphp": "^2.5",
|
||||
"squizlabs/php_codesniffer": "^3.5"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
|
||||
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
|
||||
},
|
||||
"providers": [
|
||||
"Barryvdh\\DomPDF\\ServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Barryvdh\\DomPDF\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Barry vd. Heuvel",
|
||||
"email": "barryvdh@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A DOMPDF Wrapper for Laravel",
|
||||
"keywords": [
|
||||
"dompdf",
|
||||
"laravel",
|
||||
"pdf"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
|
||||
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://fruitcake.nl",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/barryvdh",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-21T08:51:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.18.0",
|
||||
@@ -285,6 +417,56 @@
|
||||
],
|
||||
"time": "2024-02-09T16:56:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dasprid/enum",
|
||||
"version": "1.0.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/DASPRiD/Enum.git",
|
||||
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1 <9.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "*"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"DASPRiD\\Enum\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "PHP 7.1 enum implementation",
|
||||
"keywords": [
|
||||
"enum",
|
||||
"map"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/DASPRiD/Enum/issues",
|
||||
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
|
||||
},
|
||||
"time": "2025-09-16T12:23:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dflydev/dot-access-data",
|
||||
"version": "v3.0.3",
|
||||
@@ -527,6 +709,161 @@
|
||||
],
|
||||
"time": "2024-02-05T11:56:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/dompdf",
|
||||
"version": "v3.1.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/dompdf.git",
|
||||
"reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005",
|
||||
"reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dompdf/php-font-lib": "^1.0.0",
|
||||
"dompdf/php-svg-lib": "^1.0.0",
|
||||
"ext-dom": "*",
|
||||
"ext-mbstring": "*",
|
||||
"masterminds/html5": "^2.0",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-gd": "*",
|
||||
"ext-json": "*",
|
||||
"ext-zip": "*",
|
||||
"mockery/mockery": "^1.3",
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "^3.5",
|
||||
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "Needed to process images",
|
||||
"ext-gmagick": "Improves image processing performance",
|
||||
"ext-imagick": "Improves image processing performance",
|
||||
"ext-zlib": "Needed for pdf stream compression"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Dompdf\\": "src/"
|
||||
},
|
||||
"classmap": [
|
||||
"lib/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The Dompdf Community",
|
||||
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
|
||||
"homepage": "https://github.com/dompdf/dompdf",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/dompdf/issues",
|
||||
"source": "https://github.com/dompdf/dompdf/tree/v3.1.6"
|
||||
},
|
||||
"time": "2026-07-20T12:29:38+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-font-lib",
|
||||
"version": "1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-font-lib.git",
|
||||
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
||||
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"FontLib\\": "src/FontLib"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The FontLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse, export and make subsets of different types of font files.",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-font-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
|
||||
},
|
||||
"time": "2026-01-20T14:10:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-svg-lib",
|
||||
"version": "1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-svg-lib.git",
|
||||
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
||||
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0",
|
||||
"sabberworm/php-css-parser": "^8.4 || ^9.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Svg\\": "src/Svg"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-3.0-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The SvgLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse and export to PDF SVG files.",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-svg-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
|
||||
},
|
||||
"time": "2026-01-02T16:01:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dragonmantank/cron-expression",
|
||||
"version": "v3.6.0",
|
||||
@@ -658,6 +995,78 @@
|
||||
],
|
||||
"time": "2025-03-06T22:45:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "endroid/qr-code",
|
||||
"version": "6.1.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/endroid/qr-code.git",
|
||||
"reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/endroid/qr-code/zipball/5fa534856ed95649d67c0eab0cabc03ab1d8e0e2",
|
||||
"reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"bacon/bacon-qr-code": "^3.0",
|
||||
"php": "^8.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"endroid/quality": "dev-main",
|
||||
"ext-gd": "*",
|
||||
"khanamiryan/qrcode-detector-decoder": "^2.0.3",
|
||||
"setasign/fpdf": "^1.8.2"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "Enables you to write PNG images",
|
||||
"khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator",
|
||||
"roave/security-advisories": "Makes sure package versions with known security issues are not installed",
|
||||
"setasign/fpdf": "Enables you to use the PDF writer"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "6.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Endroid\\QrCode\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jeroen van den Enden",
|
||||
"email": "info@endroid.nl"
|
||||
}
|
||||
],
|
||||
"description": "Endroid QR Code",
|
||||
"homepage": "https://github.com/endroid/qr-code",
|
||||
"keywords": [
|
||||
"code",
|
||||
"endroid",
|
||||
"php",
|
||||
"qr",
|
||||
"qrcode"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/endroid/qr-code/issues",
|
||||
"source": "https://github.com/endroid/qr-code/tree/6.1.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/endroid",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-05T07:01:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "firebase/php-jwt",
|
||||
"version": "v7.1.0",
|
||||
@@ -2515,6 +2924,73 @@
|
||||
],
|
||||
"time": "2026-03-08T20:05:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "masterminds/html5",
|
||||
"version": "2.10.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Masterminds/html5-php.git",
|
||||
"reference": "fd5018f6815fff903946d0564977b44ce8010e29"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29",
|
||||
"reference": "fd5018f6815fff903946d0564977b44ce8010e29",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"php": ">=5.3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.7-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Masterminds\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Matt Butcher",
|
||||
"email": "technosophos@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Matt Farina",
|
||||
"email": "matt@mattfarina.com"
|
||||
},
|
||||
{
|
||||
"name": "Asmir Mustafic",
|
||||
"email": "goetas@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "An HTML5 parser and serializer.",
|
||||
"homepage": "http://masterminds.github.io/html5-php",
|
||||
"keywords": [
|
||||
"HTML5",
|
||||
"dom",
|
||||
"html",
|
||||
"parser",
|
||||
"querypath",
|
||||
"serializer",
|
||||
"xml"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Masterminds/html5-php/issues",
|
||||
"source": "https://github.com/Masterminds/html5-php/tree/2.10.1"
|
||||
},
|
||||
"time": "2026-06-23T18:43:15+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.10.0",
|
||||
@@ -4084,6 +4560,86 @@
|
||||
},
|
||||
"time": "2026-06-18T03:57:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabberworm/php-css-parser",
|
||||
"version": "v9.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
|
||||
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
|
||||
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-iconv": "*",
|
||||
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
|
||||
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-parallel-lint/php-parallel-lint": "1.4.0",
|
||||
"phpstan/extension-installer": "1.4.3",
|
||||
"phpstan/phpstan": "1.12.33 || 2.2.2",
|
||||
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.16",
|
||||
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11",
|
||||
"phpunit/phpunit": "8.5.52",
|
||||
"rawr/phpunit-data-provider": "3.3.1",
|
||||
"rector/rector": "1.2.10 || 2.4.6",
|
||||
"rector/type-perfect": "1.0.0 || 2.1.3",
|
||||
"squizlabs/php_codesniffer": "4.0.1",
|
||||
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-mbstring": "for parsing UTF-8 CSS"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "9.5.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/Rule/Rule.php",
|
||||
"src/RuleSet/RuleContainer.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sabberworm\\CSS\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Raphael Schweikert"
|
||||
},
|
||||
{
|
||||
"name": "Oliver Klee",
|
||||
"email": "github@oliverklee.de"
|
||||
},
|
||||
{
|
||||
"name": "Jake Hotson",
|
||||
"email": "jake.github@qzdesign.co.uk"
|
||||
}
|
||||
],
|
||||
"description": "Parser for CSS Files written in PHP",
|
||||
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
|
||||
"keywords": [
|
||||
"css",
|
||||
"parser",
|
||||
"stylesheet"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
|
||||
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0"
|
||||
},
|
||||
"time": "2026-06-18T15:10:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/clock",
|
||||
"version": "v8.1.0",
|
||||
@@ -6634,6 +7190,149 @@
|
||||
],
|
||||
"time": "2026-06-09T10:54:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "thecodingmachine/safe",
|
||||
"version": "v3.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thecodingmachine/safe.git",
|
||||
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
||||
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-parallel-lint/php-parallel-lint": "^1.4",
|
||||
"phpstan/phpstan": "^2",
|
||||
"phpunit/phpunit": "^10",
|
||||
"squizlabs/php_codesniffer": "^3.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"lib/special_cases.php",
|
||||
"generated/apache.php",
|
||||
"generated/apcu.php",
|
||||
"generated/array.php",
|
||||
"generated/bzip2.php",
|
||||
"generated/calendar.php",
|
||||
"generated/classobj.php",
|
||||
"generated/com.php",
|
||||
"generated/cubrid.php",
|
||||
"generated/curl.php",
|
||||
"generated/datetime.php",
|
||||
"generated/dir.php",
|
||||
"generated/eio.php",
|
||||
"generated/errorfunc.php",
|
||||
"generated/exec.php",
|
||||
"generated/fileinfo.php",
|
||||
"generated/filesystem.php",
|
||||
"generated/filter.php",
|
||||
"generated/fpm.php",
|
||||
"generated/ftp.php",
|
||||
"generated/funchand.php",
|
||||
"generated/gettext.php",
|
||||
"generated/gmp.php",
|
||||
"generated/gnupg.php",
|
||||
"generated/hash.php",
|
||||
"generated/ibase.php",
|
||||
"generated/ibmDb2.php",
|
||||
"generated/iconv.php",
|
||||
"generated/image.php",
|
||||
"generated/imap.php",
|
||||
"generated/info.php",
|
||||
"generated/inotify.php",
|
||||
"generated/json.php",
|
||||
"generated/ldap.php",
|
||||
"generated/libxml.php",
|
||||
"generated/lzf.php",
|
||||
"generated/mailparse.php",
|
||||
"generated/mbstring.php",
|
||||
"generated/misc.php",
|
||||
"generated/mysql.php",
|
||||
"generated/mysqli.php",
|
||||
"generated/network.php",
|
||||
"generated/oci8.php",
|
||||
"generated/opcache.php",
|
||||
"generated/openssl.php",
|
||||
"generated/outcontrol.php",
|
||||
"generated/pcntl.php",
|
||||
"generated/pcre.php",
|
||||
"generated/pgsql.php",
|
||||
"generated/posix.php",
|
||||
"generated/ps.php",
|
||||
"generated/pspell.php",
|
||||
"generated/readline.php",
|
||||
"generated/rnp.php",
|
||||
"generated/rpminfo.php",
|
||||
"generated/rrd.php",
|
||||
"generated/sem.php",
|
||||
"generated/session.php",
|
||||
"generated/shmop.php",
|
||||
"generated/sockets.php",
|
||||
"generated/sodium.php",
|
||||
"generated/solr.php",
|
||||
"generated/spl.php",
|
||||
"generated/sqlsrv.php",
|
||||
"generated/ssdeep.php",
|
||||
"generated/ssh2.php",
|
||||
"generated/stream.php",
|
||||
"generated/strings.php",
|
||||
"generated/swoole.php",
|
||||
"generated/uodbc.php",
|
||||
"generated/uopz.php",
|
||||
"generated/url.php",
|
||||
"generated/var.php",
|
||||
"generated/xdiff.php",
|
||||
"generated/xml.php",
|
||||
"generated/xmlrpc.php",
|
||||
"generated/yaml.php",
|
||||
"generated/yaz.php",
|
||||
"generated/zip.php",
|
||||
"generated/zlib.php"
|
||||
],
|
||||
"classmap": [
|
||||
"lib/DateTime.php",
|
||||
"lib/DateTimeImmutable.php",
|
||||
"lib/Exceptions/",
|
||||
"generated/Exceptions/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
|
||||
"support": {
|
||||
"issues": "https://github.com/thecodingmachine/safe/issues",
|
||||
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/OskarStark",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/shish",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/silasjoisten",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/staabm",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-04T18:08:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tijsverkoyen/css-to-inline-styles",
|
||||
"version": "v2.4.0",
|
||||
|
||||
@@ -78,9 +78,11 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
'locale' => env('APP_LOCALE', 'es'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'es'),
|
||||
|
||||
'supported_locales' => ['es', 'en'],
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
|
||||
9
config/login-security.php
Normal file
9
config/login-security.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'max_attempts' => (int) env('AUTH_MAX_LOGIN_ATTEMPTS', 3),
|
||||
'attempt_window_minutes' => (int) env('AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES', 30),
|
||||
'lock_minutes' => (int) env('AUTH_LOGIN_LOCK_MINUTES', 15),
|
||||
'rate_limit_per_minute' => (int) env('AUTH_LOGIN_RATE_LIMIT_PER_MINUTE', 10),
|
||||
'ip_rate_limit_per_minute' => (int) env('AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE', 30),
|
||||
];
|
||||
@@ -0,0 +1,27 @@
|
||||
<?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('tickets', function (Blueprint $table): void {
|
||||
$table->foreignId('source_purchase_id')
|
||||
->nullable()
|
||||
->after('description')
|
||||
->constrained('compras')
|
||||
->cascadeOnUpdate()
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('source_purchase_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?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('users', function (Blueprint $table): void {
|
||||
$table->unsignedSmallInteger('failed_login_attempts')->default(0);
|
||||
$table->timestamp('last_failed_login_at')->nullable();
|
||||
$table->timestamp('locked_until')->nullable()->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->dropIndex(['locked_until']);
|
||||
$table->dropColumn([
|
||||
'failed_login_attempts',
|
||||
'last_failed_login_at',
|
||||
'locked_until',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?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('login_attempts', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')
|
||||
->nullable()
|
||||
->constrained()
|
||||
->cascadeOnUpdate()
|
||||
->nullOnDelete();
|
||||
$table->string('email_fingerprint', 64);
|
||||
$table->string('tenant_codigo')->nullable();
|
||||
$table->string('outcome', 32);
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->string('user_agent', 1024)->nullable();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(['email_fingerprint', 'created_at']);
|
||||
$table->index(['user_id', 'created_at']);
|
||||
$table->index(['ip_address', 'created_at']);
|
||||
$table->index(['outcome', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('login_attempts');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
152
database/seeders/AuthorizationSeeder.php
Normal file
152
database/seeders/AuthorizationSeeder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CategorySeeder extends Seeder
|
||||
@@ -12,6 +13,12 @@ class CategorySeeder extends Seeder
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$tenant = Tenant::query()->where('codigo', 'sonder')->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$categories = [
|
||||
'Remeras',
|
||||
'Pantalones',
|
||||
@@ -23,8 +30,21 @@ class CategorySeeder extends Seeder
|
||||
foreach ($categories as $nombre) {
|
||||
Category::firstOrCreate([
|
||||
'nombre' => $nombre,
|
||||
'tenant_code' => null,
|
||||
'tenant_code' => $tenant->codigo,
|
||||
]);
|
||||
}
|
||||
|
||||
$indumentaria = Category::updateOrCreate(
|
||||
[
|
||||
'nombre' => 'Indumentaria',
|
||||
'tenant_code' => $tenant->codigo,
|
||||
],
|
||||
['categoria_id' => null],
|
||||
);
|
||||
|
||||
Category::query()
|
||||
->where('nombre', 'Remeras')
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->update(['categoria_id' => $indumentaria->id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ class DatabaseSeeder extends Seeder
|
||||
]);
|
||||
|
||||
$this->call([
|
||||
WebsiteTypeSeeder::class,
|
||||
AuthorizationSeeder::class,
|
||||
SocialMediaSeeder::class,
|
||||
TenantSeeder::class,
|
||||
AttributeSeeder::class,
|
||||
|
||||
@@ -30,11 +30,15 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
||||
|
||||
$ticketCategory = Category::query()->firstOrCreate([
|
||||
'nombre' => 'Entradas',
|
||||
'tenant_code' => null,
|
||||
'tenant_code' => $tenant->codigo,
|
||||
]);
|
||||
$foodCategory = Category::query()->firstOrCreate([
|
||||
'nombre' => 'Gastronomía',
|
||||
'tenant_code' => null,
|
||||
'tenant_code' => $tenant->codigo,
|
||||
]);
|
||||
$parkingCategory = Category::query()->firstOrCreate([
|
||||
'nombre' => 'Estacionamiento',
|
||||
'tenant_code' => $tenant->codigo,
|
||||
]);
|
||||
|
||||
$dates = ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'];
|
||||
@@ -69,8 +73,8 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
||||
['slug' => 'pancho', 'nombre' => 'Pancho', 'precio' => 4000, 'category_id' => $foodCategory->id],
|
||||
['slug' => 'coca-cola-500ml', 'nombre' => 'Coca Cola 500ml', 'precio' => 3000, 'category_id' => $foodCategory->id],
|
||||
['slug' => 'agua-mineral-1l', 'nombre' => 'Agua Mineral 1L', 'precio' => 2500, 'category_id' => $foodCategory->id],
|
||||
['slug' => 'estacionamiento-auto', 'nombre' => 'Estacionamiento Auto', 'precio' => 5000, 'category_id' => $ticketCategory->id],
|
||||
['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'category_id' => $ticketCategory->id],
|
||||
['slug' => 'estacionamiento-auto', 'nombre' => 'Estacionamiento Auto', 'precio' => 5000, 'category_id' => $parkingCategory->id],
|
||||
['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'category_id' => $parkingCategory->id],
|
||||
];
|
||||
|
||||
$createdItems = [];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -236,11 +236,11 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
||||
|
||||
$category = Category::query()
|
||||
->where('nombre', $metadata['category_name'])
|
||||
->whereNull('tenant_code')
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->first();
|
||||
|
||||
if (! $category instanceof Category) {
|
||||
throw new RuntimeException("Category '{$metadata['category_name']}' not found.");
|
||||
throw new RuntimeException("Category '{$metadata['category_name']}' not found for tenant '{$tenant->codigo}'.");
|
||||
}
|
||||
|
||||
$attributeCodes = Attribute::query()
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
105
database/seeders/WebsiteTypeSeeder.php
Normal file
105
database/seeders/WebsiteTypeSeeder.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?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(
|
||||
['nombre' => 'carousel'],
|
||||
[
|
||||
'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',
|
||||
],
|
||||
],
|
||||
'database_rules' => [
|
||||
'$' => 'required|array',
|
||||
'$.*' => 'required|integer|distinct|exists:attachments,id',
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$onTicket = WebsiteType::query()->updateOrCreate(
|
||||
['codigo' => 'onticket'],
|
||||
['nombre' => 'OnTicket'],
|
||||
);
|
||||
|
||||
$onTicket->extras()->updateOrCreate(
|
||||
['nombre' => 'heroConfig'],
|
||||
[
|
||||
'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',
|
||||
],
|
||||
],
|
||||
'database_rules' => [
|
||||
'$' => 'required|array',
|
||||
'title_html' => 'nullable|string',
|
||||
'description_html' => 'nullable|string',
|
||||
'button_text' => 'nullable|string',
|
||||
'button_href' => 'nullable|string',
|
||||
'background_image_id' => 'nullable|integer|exists:attachments,id',
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$onTicket->extras()->updateOrCreate(
|
||||
['nombre' => 'eventConfig'],
|
||||
[
|
||||
'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' => [],
|
||||
'database_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',
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
91
docs/authorization.md
Normal file
91
docs/authorization.md
Normal 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.
|
||||
103
lang/en/api.php
Normal file
103
lang/en/api.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'auth' => [
|
||||
'unauthenticated' => 'Unauthenticated.',
|
||||
'invalid_credentials' => 'Email or password is incorrect.',
|
||||
'account_locked' => 'The account is temporarily locked. Please try again later.',
|
||||
'login_success' => 'Signed in successfully.',
|
||||
'logout_success' => 'Signed out successfully.',
|
||||
'register_success' => 'User registered successfully.',
|
||||
'password_reset_requested' => 'If the email is registered, you will receive a code to reset your password.',
|
||||
'password_reset_invalid' => 'The password recovery request is invalid or has already been used.',
|
||||
'password_updated' => 'Password updated successfully.',
|
||||
'reset_code_invalid' => 'The code you entered is invalid.',
|
||||
'reset_code_valid' => 'Code validated successfully.',
|
||||
'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.',
|
||||
'request_expired' => 'The authentication request expired. Please try again.',
|
||||
'invalid_return_url' => 'The return URL is invalid.',
|
||||
'oauth_code_expired' => 'The authentication code expired or has already been used.',
|
||||
'tenant_mismatch' => 'The tenant does not match the authentication request.',
|
||||
'google_email_required' => 'Google did not return an email for the account.',
|
||||
'google_email_unverified' => 'The Google account must have a verified email.',
|
||||
],
|
||||
'cart' => [
|
||||
'item_added' => 'Product added to cart.',
|
||||
'quantity_updated' => 'Product quantity updated.',
|
||||
'item_removed' => 'Product removed from cart.',
|
||||
'positive_quantity' => 'The quantity must be greater than zero.',
|
||||
'insufficient_stock' => 'There is not enough stock for the requested product. Maximum available: :max.',
|
||||
'max_quantity' => 'You can add a maximum of :max.',
|
||||
'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
|
||||
'empty_bundle' => 'The bundle has no components.',
|
||||
'variant_required' => 'You must select a variant for this item.',
|
||||
],
|
||||
'purchase' => [
|
||||
'source_required' => 'A cart or direct item is required.',
|
||||
'payment_method_required' => 'The purchase payment method must be selected before finalizing.',
|
||||
'not_editable' => 'The purchase is no longer editable.',
|
||||
'item_not_editable' => 'The purchase item is no longer editable.',
|
||||
'insufficient_stock' => 'There is not enough stock available.',
|
||||
'cannot_confirm' => 'A cancelled, rejected, or expired purchase cannot be confirmed.',
|
||||
'inconsistent_reservation' => 'The purchase has an inconsistent stock reservation.',
|
||||
'paid_cannot_cancel' => 'A paid purchase cannot be cancelled.',
|
||||
'direct_item_max_stock' => 'There is not enough stock. Maximum available: :max.',
|
||||
'empty_cart' => 'The selected cart does not contain items.',
|
||||
'catalog_item_missing' => 'One or more catalog items could not be loaded.',
|
||||
'catalog_item_wrong_tenant' => 'One or more catalog items do not belong to the tenant.',
|
||||
'inactive_cart' => 'The selected cart is no longer active.',
|
||||
'not_available_for_payment' => 'The purchase is no longer available for payment.',
|
||||
'not_available_for_review' => 'The purchase is no longer available for review.',
|
||||
],
|
||||
'ticket' => [
|
||||
'not_available' => 'One or more tickets are not available.',
|
||||
'positive_quantity' => 'The number of tickets to generate must be greater than zero.',
|
||||
'empty_bundle' => 'Bundle :bundle has no components.',
|
||||
'disabled' => 'Product :product does not have tickets enabled.',
|
||||
'expired' => 'Product :product has reached its maximum usage date.',
|
||||
'invalid_variant' => 'Variant :variant does not belong to product :product.',
|
||||
'purchase_without_user' => 'Purchase :purchase does not have an associated user.',
|
||||
'product_not_found' => 'The product for purchase item :purchase_item was not found.',
|
||||
],
|
||||
'integration' => [
|
||||
'not_configured' => 'The integration is not configured for this tenant.',
|
||||
'configured' => 'Integration configured successfully.',
|
||||
'validation_failed' => 'Configuration validation failed: :error',
|
||||
'invalid_payment_method' => 'Invalid payment method.',
|
||||
'account_info_failed' => 'Could not retrieve account information.',
|
||||
'qr_generation_failed' => 'Could not generate the QR code.',
|
||||
'webhook_failed' => 'The webhook could not be processed.',
|
||||
],
|
||||
'mail' => [
|
||||
'test_sent' => 'Test email sent successfully.',
|
||||
],
|
||||
'catalog' => [
|
||||
'standard_with_components' => 'A standard item cannot have components.',
|
||||
'duplicate_component' => 'The component is duplicated.',
|
||||
'component_wrong_tenant' => 'The item does not belong to the bundle tenant.',
|
||||
'invalid_component' => 'The component must be a standard item other than the bundle.',
|
||||
'component_variant_required' => 'You must select a variant for this component.',
|
||||
'component_variant_forbidden' => 'A component with direct inventory cannot have a variant.',
|
||||
'component_variant_invalid' => 'The variant does not belong to the selected component.',
|
||||
'bundle_component_required' => 'A bundle must have at least one component.',
|
||||
'bundle_field_forbidden' => ':field is not allowed for a bundle.',
|
||||
'attachment_not_found' => 'The specified attachment does not exist.',
|
||||
'attribute_not_found' => 'Attribute :attribute does not exist for the item tenant.',
|
||||
'managed_inventory_fields' => 'inventory_id, reserved_stock, and sold_units are managed internally.',
|
||||
'attribute_not_on_item' => 'The attribute does not belong to the catalog item.',
|
||||
'invalid_effective_date_range' => 'The effective maximum date must be after or equal to the effective minimum date.',
|
||||
'variants_required' => 'An item with attribute_codes must have variants.',
|
||||
'variants_forbidden' => 'An item without attribute_codes cannot have variants.',
|
||||
'direct_inventory_forbidden' => 'An item with variants cannot have direct inventory.',
|
||||
],
|
||||
'menu' => [
|
||||
'schema_required' => 'The schema is required for static menus.',
|
||||
'not_found' => 'The specified menu does not exist.',
|
||||
],
|
||||
'errors' => [
|
||||
'forbidden' => 'You do not have permission to perform this action.',
|
||||
'not_found' => 'The requested resource was not found.',
|
||||
'method_not_allowed' => 'The HTTP method is not allowed for this route.',
|
||||
'request_failed' => 'The request could not be processed.',
|
||||
],
|
||||
];
|
||||
57
lang/en/validation.php
Normal file
57
lang/en/validation.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'after_or_equal' => 'The :attribute must be a date after or equal to :date.',
|
||||
'array' => 'The :attribute must be an array.',
|
||||
'boolean' => 'The :attribute field must be true or false.',
|
||||
'confirmed' => 'The :attribute confirmation does not match.',
|
||||
'date' => 'The :attribute must be a valid date.',
|
||||
'distinct' => 'The :attribute field has a duplicate value.',
|
||||
'email' => 'The :attribute must be a valid email address.',
|
||||
'exists' => 'The selected :attribute is invalid.',
|
||||
'file' => 'The :attribute must be a file.',
|
||||
'integer' => 'The :attribute must be an integer.',
|
||||
'max' => [
|
||||
'array' => 'The :attribute must not have more than :max items.',
|
||||
'file' => 'The :attribute must not be greater than :max kilobytes.',
|
||||
'numeric' => 'The :attribute must not be greater than :max.',
|
||||
'string' => 'The :attribute must not be greater than :max characters.',
|
||||
],
|
||||
'min' => [
|
||||
'array' => 'The :attribute must have at least :min items.',
|
||||
'file' => 'The :attribute must be at least :min kilobytes.',
|
||||
'numeric' => 'The :attribute must be at least :min.',
|
||||
'string' => 'The :attribute must be at least :min characters.',
|
||||
],
|
||||
'numeric' => 'The :attribute must be a number.',
|
||||
'password' => [
|
||||
'letters' => 'The :attribute must contain at least one letter.',
|
||||
'mixed' => 'The :attribute must contain at least one uppercase and one lowercase letter.',
|
||||
'numbers' => 'The :attribute must contain at least one number.',
|
||||
'symbols' => 'The :attribute must contain at least one symbol.',
|
||||
'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different one.',
|
||||
],
|
||||
'prohibited' => 'The :attribute field is prohibited.',
|
||||
'regex' => 'The :attribute format is invalid.',
|
||||
'required' => 'The :attribute field is required.',
|
||||
'required_with' => 'The :attribute field is required when :values is present.',
|
||||
'required_without' => 'The :attribute field is required when :values is not present.',
|
||||
'string' => 'The :attribute must be a string.',
|
||||
'unique' => 'The :attribute has already been taken.',
|
||||
'url' => 'The :attribute must be a valid URL.',
|
||||
'uuid' => 'The :attribute must be a valid UUID.',
|
||||
'attributes' => [
|
||||
'cantidad' => 'quantity',
|
||||
'catalog_item_id' => 'product',
|
||||
'codigo' => 'code',
|
||||
'dni' => 'ID number',
|
||||
'email' => 'email',
|
||||
'nombre_apellido' => 'full name',
|
||||
'oauth_code' => 'authentication code',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password confirmation',
|
||||
'telefono' => 'phone number',
|
||||
'tenant_codigo' => 'tenant',
|
||||
'variant_id' => 'variant',
|
||||
],
|
||||
];
|
||||
103
lang/es/api.php
Normal file
103
lang/es/api.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'auth' => [
|
||||
'unauthenticated' => 'No autenticado.',
|
||||
'invalid_credentials' => 'Email o contraseña incorrectos.',
|
||||
'account_locked' => 'La cuenta está bloqueada temporalmente. Intenta nuevamente más tarde.',
|
||||
'login_success' => 'Sesión iniciada correctamente.',
|
||||
'logout_success' => 'Sesión cerrada correctamente.',
|
||||
'register_success' => 'Usuario registrado correctamente.',
|
||||
'password_reset_requested' => 'Si el email está registrado, recibirás un código para recuperar tu contraseña.',
|
||||
'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
|
||||
'password_updated' => 'Contraseña modificada correctamente.',
|
||||
'reset_code_invalid' => 'El código ingresado es inválido.',
|
||||
'reset_code_valid' => 'Código validado correctamente.',
|
||||
'invalid_tenant_or_return_url' => 'El tenant o la URL de retorno no son válidos.',
|
||||
'request_expired' => 'La solicitud de autenticación expiró. Intenta nuevamente.',
|
||||
'invalid_return_url' => 'La URL de retorno no es válida.',
|
||||
'oauth_code_expired' => 'El código de autenticación expiró o ya fue utilizado.',
|
||||
'tenant_mismatch' => 'El tenant no coincide con la solicitud de autenticación.',
|
||||
'google_email_required' => 'Google no devolvió un email para la cuenta.',
|
||||
'google_email_unverified' => 'La cuenta de Google debe tener el email verificado.',
|
||||
],
|
||||
'cart' => [
|
||||
'item_added' => 'Producto agregado al carrito.',
|
||||
'quantity_updated' => 'Cantidad de producto actualizada.',
|
||||
'item_removed' => 'Producto eliminado del carrito.',
|
||||
'positive_quantity' => 'La cantidad debe ser mayor a cero.',
|
||||
'insufficient_stock' => 'Stock insuficiente para el producto solicitado. Máximo disponible: :max.',
|
||||
'max_quantity' => 'El máximo que se puede agregar es :max.',
|
||||
'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
|
||||
'empty_bundle' => 'El bundle no tiene componentes.',
|
||||
'variant_required' => 'Debe seleccionar una variante para este ítem.',
|
||||
],
|
||||
'purchase' => [
|
||||
'source_required' => 'Se requiere un carrito o un producto directo.',
|
||||
'payment_method_required' => 'Debes seleccionar el método de pago antes de finalizar la compra.',
|
||||
'not_editable' => 'La compra ya no se puede modificar.',
|
||||
'item_not_editable' => 'El producto de la compra ya no se puede modificar.',
|
||||
'insufficient_stock' => 'No hay suficiente stock disponible.',
|
||||
'cannot_confirm' => 'Una compra cancelada, rechazada o vencida no se puede confirmar.',
|
||||
'inconsistent_reservation' => 'La compra tiene una reserva de stock inconsistente.',
|
||||
'paid_cannot_cancel' => 'Una compra pagada no se puede cancelar.',
|
||||
'direct_item_max_stock' => 'Stock insuficiente. Máximo disponible: :max.',
|
||||
'empty_cart' => 'El carrito seleccionado no contiene productos.',
|
||||
'catalog_item_missing' => 'No se pudieron cargar uno o más productos del catálogo.',
|
||||
'catalog_item_wrong_tenant' => 'Uno o más productos no pertenecen al tenant.',
|
||||
'inactive_cart' => 'El carrito seleccionado ya no está activo.',
|
||||
'not_available_for_payment' => 'La compra ya no está disponible para el pago.',
|
||||
'not_available_for_review' => "La compra ya no est\u{00E1} disponible para revisi\u{00F3}n.",
|
||||
],
|
||||
'ticket' => [
|
||||
'not_available' => 'Uno o más tickets no están disponibles.',
|
||||
'positive_quantity' => 'La cantidad de tickets a generar debe ser mayor a cero.',
|
||||
'empty_bundle' => 'El bundle :bundle no tiene componentes.',
|
||||
'disabled' => 'El producto :product no tiene tickets habilitados.',
|
||||
'expired' => 'El producto :product alcanzó su fecha máxima de uso.',
|
||||
'invalid_variant' => 'La variante :variant no pertenece al producto :product.',
|
||||
'purchase_without_user' => 'La compra :purchase no tiene un usuario asociado.',
|
||||
'product_not_found' => 'No se encontró el producto de la línea de compra :purchase_item.',
|
||||
],
|
||||
'integration' => [
|
||||
'not_configured' => 'La integración no está configurada para este tenant.',
|
||||
'configured' => 'Integración configurada correctamente.',
|
||||
'validation_failed' => 'Error validando la configuración: :error',
|
||||
'invalid_payment_method' => 'Método de pago inválido.',
|
||||
'account_info_failed' => 'No se pudo obtener la información de la cuenta.',
|
||||
'qr_generation_failed' => 'No se pudo generar el código QR.',
|
||||
'webhook_failed' => 'No se pudo procesar el webhook.',
|
||||
],
|
||||
'mail' => [
|
||||
'test_sent' => 'Correo de prueba enviado correctamente.',
|
||||
],
|
||||
'catalog' => [
|
||||
'standard_with_components' => 'Un ítem standard no puede tener componentes.',
|
||||
'duplicate_component' => 'El componente está duplicado.',
|
||||
'component_wrong_tenant' => 'El ítem no pertenece al tenant del bundle.',
|
||||
'invalid_component' => 'El componente debe ser un ítem standard distinto del bundle.',
|
||||
'component_variant_required' => 'Debe seleccionar una variante para este componente.',
|
||||
'component_variant_forbidden' => 'El componente con inventario directo no admite una variante.',
|
||||
'component_variant_invalid' => 'La variante no pertenece al componente indicado.',
|
||||
'bundle_component_required' => 'Un bundle debe tener al menos un componente.',
|
||||
'bundle_field_forbidden' => ':field no se admite para un bundle.',
|
||||
'attachment_not_found' => 'El attachment indicado no existe.',
|
||||
'attribute_not_found' => 'El atributo :attribute no existe para el tenant del ítem.',
|
||||
'managed_inventory_fields' => 'inventory_id, reserved_stock y sold_units son administrados internamente.',
|
||||
'attribute_not_on_item' => 'El atributo no pertenece al ítem de catálogo.',
|
||||
'invalid_effective_date_range' => 'La fecha máxima efectiva debe ser posterior o igual a la fecha mínima efectiva.',
|
||||
'variants_required' => 'Un ítem con attribute_codes debe tener variantes.',
|
||||
'variants_forbidden' => 'Un ítem sin attribute_codes no puede tener variantes.',
|
||||
'direct_inventory_forbidden' => 'Un ítem con variantes no puede tener inventario directo.',
|
||||
],
|
||||
'menu' => [
|
||||
'schema_required' => 'El schema es obligatorio para los menús estáticos.',
|
||||
'not_found' => 'El menú indicado no existe.',
|
||||
],
|
||||
'errors' => [
|
||||
'forbidden' => 'No tienes permiso para realizar esta acción.',
|
||||
'not_found' => 'El recurso solicitado no fue encontrado.',
|
||||
'method_not_allowed' => 'El método HTTP no está permitido para esta ruta.',
|
||||
'request_failed' => 'No se pudo procesar la solicitud.',
|
||||
],
|
||||
];
|
||||
57
lang/es/validation.php
Normal file
57
lang/es/validation.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'after_or_equal' => ':Attribute debe ser una fecha posterior o igual a :date.',
|
||||
'array' => ':Attribute debe ser un arreglo.',
|
||||
'boolean' => 'El campo :attribute debe ser verdadero o falso.',
|
||||
'confirmed' => 'La confirmación de :attribute no coincide.',
|
||||
'date' => ':Attribute no es una fecha válida.',
|
||||
'distinct' => 'El campo :attribute tiene un valor duplicado.',
|
||||
'email' => ':Attribute debe ser una dirección de email válida.',
|
||||
'exists' => 'El valor seleccionado para :attribute no es válido.',
|
||||
'file' => ':Attribute debe ser un archivo.',
|
||||
'integer' => ':Attribute debe ser un número entero.',
|
||||
'max' => [
|
||||
'array' => ':Attribute no debe tener más de :max elementos.',
|
||||
'file' => ':Attribute no debe superar los :max kilobytes.',
|
||||
'numeric' => ':Attribute no debe ser mayor que :max.',
|
||||
'string' => ':Attribute no debe tener más de :max caracteres.',
|
||||
],
|
||||
'min' => [
|
||||
'array' => ':Attribute debe tener al menos :min elementos.',
|
||||
'file' => ':Attribute debe pesar al menos :min kilobytes.',
|
||||
'numeric' => ':Attribute debe ser al menos :min.',
|
||||
'string' => ':Attribute debe tener al menos :min caracteres.',
|
||||
],
|
||||
'numeric' => ':Attribute debe ser un número.',
|
||||
'password' => [
|
||||
'letters' => ':Attribute debe contener al menos una letra.',
|
||||
'mixed' => ':Attribute debe contener al menos una letra mayúscula y una minúscula.',
|
||||
'numbers' => ':Attribute debe contener al menos un número.',
|
||||
'symbols' => ':Attribute debe contener al menos un símbolo.',
|
||||
'uncompromised' => ':Attribute aparece en una filtración de datos. Elige una contraseña diferente.',
|
||||
],
|
||||
'prohibited' => 'El campo :attribute está prohibido.',
|
||||
'regex' => 'El formato de :attribute no es válido.',
|
||||
'required' => 'El campo :attribute es obligatorio.',
|
||||
'required_with' => 'El campo :attribute es obligatorio cuando :values está presente.',
|
||||
'required_without' => 'El campo :attribute es obligatorio cuando :values no está presente.',
|
||||
'string' => ':Attribute debe ser texto.',
|
||||
'unique' => 'El :attribute ya está en uso.',
|
||||
'url' => ':Attribute debe ser una URL válida.',
|
||||
'uuid' => ':Attribute debe ser un UUID válido.',
|
||||
'attributes' => [
|
||||
'cantidad' => 'cantidad',
|
||||
'catalog_item_id' => 'producto',
|
||||
'codigo' => 'código',
|
||||
'dni' => 'DNI',
|
||||
'email' => 'email',
|
||||
'nombre_apellido' => 'nombre y apellido',
|
||||
'oauth_code' => 'código de autenticación',
|
||||
'password' => 'contraseña',
|
||||
'password_confirmation' => 'confirmación de contraseña',
|
||||
'telefono' => 'teléfono',
|
||||
'tenant_codigo' => 'tenant',
|
||||
'variant_id' => 'variante',
|
||||
],
|
||||
];
|
||||
213
resources/views/pdf/tickets.blade.php
Normal file
213
resources/views/pdf/tickets.blade.php
Normal file
@@ -0,0 +1,213 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||
<style>
|
||||
@page { margin: 0; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
background: #f4f7f5;
|
||||
color: #17211b;
|
||||
font-family: DejaVu Sans, sans-serif;
|
||||
font-size: 11px;
|
||||
margin: 0;
|
||||
}
|
||||
.ticket {
|
||||
background: #f4f7f5;
|
||||
min-height: 1120px;
|
||||
padding: 0 54px;
|
||||
position: relative;
|
||||
}
|
||||
.ticket + .ticket { page-break-before: always; }
|
||||
.accent { background: {{ $primaryColor }}; height: 8px; margin: 0 -54px; }
|
||||
.header {
|
||||
background: {{ $headerBackgroundColor }};
|
||||
color: {{ $headerTextColor }};
|
||||
height: 132px;
|
||||
margin: 0 -54px;
|
||||
padding: 32px 54px 0;
|
||||
}
|
||||
.header-table, .meta-table, .footer-table { border-collapse: collapse; width: 100%; }
|
||||
.brand-cell { vertical-align: middle; }
|
||||
.type-cell { text-align: right; vertical-align: middle; width: 190px; }
|
||||
.logo {
|
||||
height: 64px;
|
||||
max-width: 180px;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tenant-name {
|
||||
display: inline-block;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
max-width: 340px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.ticket-type {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
color: {{ $primaryColor }};
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1.25px;
|
||||
padding: 8px 13px;
|
||||
}
|
||||
.content { padding-top: 36px; text-align: center; }
|
||||
.eyebrow {
|
||||
color: {{ $primaryColor }};
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1.4px;
|
||||
margin: 0 0 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ticket-name {
|
||||
font-size: 27px;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
margin: 0 auto 23px;
|
||||
max-width: 570px;
|
||||
}
|
||||
.meta-card {
|
||||
background: #fff;
|
||||
border: 1px solid #dfe7e2;
|
||||
border-radius: 10px;
|
||||
margin: 0 auto 24px;
|
||||
padding: 15px 18px;
|
||||
text-align: left;
|
||||
width: 500px;
|
||||
}
|
||||
.meta-cell {
|
||||
border-right: 1px solid #e5ebe7;
|
||||
padding: 0 14px;
|
||||
vertical-align: top;
|
||||
width: 33.33%;
|
||||
}
|
||||
.meta-cell:first-child { padding-left: 0; }
|
||||
.meta-cell:last-child { border-right: 0; padding-right: 0; }
|
||||
.meta-label {
|
||||
color: #758078;
|
||||
font-size: 8px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.9px;
|
||||
margin-bottom: 5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.meta-value { color: #26322a; font-size: 12px; font-weight: bold; }
|
||||
.qr-card {
|
||||
background: #fff;
|
||||
border: 1px solid #dfe7e2;
|
||||
border-radius: 16px;
|
||||
display: inline-block;
|
||||
padding: 18px 22px 17px;
|
||||
}
|
||||
.qr-frame {
|
||||
border: 3px solid {{ $primaryColor }};
|
||||
border-radius: 14px;
|
||||
padding: 11px;
|
||||
}
|
||||
.qr { display: block; height: 276px; width: 276px; }
|
||||
.scan-label {
|
||||
color: #26322a;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin: 14px 0 3px;
|
||||
}
|
||||
.scan-help { color: #77827b; font-size: 9px; margin: 0; }
|
||||
.notice {
|
||||
background: #eaf1ed;
|
||||
border-left: 4px solid {{ $primaryColor }};
|
||||
border-radius: 6px;
|
||||
color: #445049;
|
||||
line-height: 1.45;
|
||||
margin: 22px auto 0;
|
||||
padding: 11px 14px;
|
||||
text-align: left;
|
||||
width: 500px;
|
||||
}
|
||||
.notice strong { color: #26322a; }
|
||||
.footer {
|
||||
border-top: 1px solid #dfe7e2;
|
||||
bottom: 32px;
|
||||
color: #7b857e;
|
||||
font-size: 8px;
|
||||
left: 54px;
|
||||
padding-top: 11px;
|
||||
position: absolute;
|
||||
right: 54px;
|
||||
}
|
||||
.footer-right { text-align: right; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@foreach ($tickets as $ticket)
|
||||
<section class="ticket">
|
||||
<div class="accent"></div>
|
||||
|
||||
<header class="header">
|
||||
<table class="header-table" role="presentation">
|
||||
<tr>
|
||||
<td class="brand-cell">
|
||||
@if ($logoDataUri)
|
||||
<img class="logo" src="{{ $logoDataUri }}" alt="Logo de {{ $tenant->nombre }}">
|
||||
@else
|
||||
<span class="tenant-name">{{ $tenant->nombre }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="type-cell">
|
||||
<span class="ticket-type">TICKET DIGITAL</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<p class="eyebrow">Tu acceso</p>
|
||||
<h1 class="ticket-name">{{ $ticket->name }}</h1>
|
||||
|
||||
<div class="meta-card">
|
||||
<table class="meta-table" role="presentation">
|
||||
<tr>
|
||||
<td class="meta-cell">
|
||||
<div class="meta-label">Número de ticket</div>
|
||||
<div class="meta-value">#{{ $ticket->id }}</div>
|
||||
</td>
|
||||
<td class="meta-cell">
|
||||
<div class="meta-label">Desde</div>
|
||||
<div class="meta-value">{{ $ticket->starts_at?->format('d/m/Y') ?? 'Sin fecha inicial' }}</div>
|
||||
</td>
|
||||
<td class="meta-cell">
|
||||
<div class="meta-label">Hasta</div>
|
||||
<div class="meta-value">{{ $ticket->expires_at?->format('d/m/Y') ?? 'Sin vencimiento' }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="qr-card">
|
||||
<div class="qr-frame">
|
||||
<img class="qr" src="{{ $qrCodes[$ticket->id] }}" alt="Código QR del ticket {{ $ticket->id }}">
|
||||
</div>
|
||||
<p class="scan-label">Escaneá este código al ingresar</p>
|
||||
<p class="scan-help">Aumentá el brillo de la pantalla para una lectura más rápida.</p>
|
||||
</div>
|
||||
|
||||
<div class="notice">
|
||||
<strong>Importante:</strong> este ticket es personal. No compartas el código QR con otras personas.
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<table class="footer-table" role="presentation">
|
||||
<tr>
|
||||
<td>{{ $tenant->nombre }}</td>
|
||||
<td class="footer-right">Conservá este ticket hasta finalizar tu ingreso.</td>
|
||||
</tr>
|
||||
</table>
|
||||
</footer>
|
||||
</section>
|
||||
@endforeach
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,6 +4,7 @@ namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Auth\Models\LoginAttempt;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
@@ -35,7 +36,9 @@ class LoginControllerTest extends TestCase
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('message', 'Sesion iniciada correctamente.')
|
||||
->assertHeader('Content-Language', 'es')
|
||||
->assertJsonPath('code', 'auth.login_success')
|
||||
->assertJsonPath('message', 'Sesión iniciada correctamente.')
|
||||
->assertJsonPath('token_type', 'Bearer')
|
||||
->assertJsonPath('user.id', $user->id)
|
||||
->assertJsonPath('user.nombre_apellido', 'Grace Hopper')
|
||||
@@ -85,7 +88,8 @@ class LoginControllerTest extends TestCase
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/logout')
|
||||
->assertOk()
|
||||
->assertJsonPath('message', 'Sesion cerrada correctamente.');
|
||||
->assertJsonPath('code', 'auth.logout_success')
|
||||
->assertJsonPath('message', 'Sesión cerrada correctamente.');
|
||||
|
||||
$this->assertDatabaseCount('personal_access_tokens', 0);
|
||||
}
|
||||
@@ -99,11 +103,190 @@ class LoginControllerTest extends TestCase
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'grace@example.com',
|
||||
$this->withHeader('User-Agent', 'Shopit login test')
|
||||
->postJson('/api/login', [
|
||||
'email' => 'grace@example.com',
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
|
||||
|
||||
$user = User::query()->where('email', 'grace@example.com')->sole();
|
||||
|
||||
$this->assertSame(1, $user->failed_login_attempts);
|
||||
$this->assertNotNull($user->last_failed_login_at);
|
||||
$this->assertDatabaseHas('login_attempts', [
|
||||
'user_id' => $user->id,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'Shopit login test',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_locks_an_account_after_the_maximum_failed_attempts(): void
|
||||
{
|
||||
config([
|
||||
'login-security.max_attempts' => 3,
|
||||
'login-security.lock_minutes' => 15,
|
||||
'login-security.rate_limit_per_minute' => 100,
|
||||
'login-security.ip_rate_limit_per_minute' => 100,
|
||||
]);
|
||||
$this->travelTo(now()->startOfSecond());
|
||||
|
||||
$tenant = $this->createTenant('locked');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'locked@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
$payload = [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
|
||||
];
|
||||
|
||||
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||
$this->postJson('/api/login', $payload)->assertUnprocessable();
|
||||
}
|
||||
|
||||
$this->postJson('/api/login', $payload)
|
||||
->assertTooManyRequests()
|
||||
->assertJsonPath('code', 'auth.account_locked');
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame(3, $user->failed_login_attempts);
|
||||
$this->assertTrue($user->locked_until->equalTo(now()->addMinutes(15)));
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
...$payload,
|
||||
'password' => 'secret123',
|
||||
])
|
||||
->assertTooManyRequests()
|
||||
->assertHeader('Retry-After', '900')
|
||||
->assertJsonPath('code', 'auth.account_locked')
|
||||
->assertJsonPath('retry_after', 900);
|
||||
|
||||
$this->assertDatabaseCount('login_attempts', 4);
|
||||
$this->assertDatabaseHas('login_attempts', [
|
||||
'user_id' => $user->id,
|
||||
'outcome' => LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_a_successful_login_resets_failures_and_is_audited(): void
|
||||
{
|
||||
$tenant = $this->createTenant('successful');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'successful@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 2,
|
||||
'last_failed_login_at' => now()->subMinute(),
|
||||
])->save();
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'secret123',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertOk();
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame(0, $user->failed_login_attempts);
|
||||
$this->assertNull($user->last_failed_login_at);
|
||||
$this->assertNull($user->locked_until);
|
||||
$this->assertDatabaseHas('login_attempts', [
|
||||
'user_id' => $user->id,
|
||||
'outcome' => LoginAttempt::OUTCOME_SUCCESS,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_an_expired_lock_allows_login_again(): void
|
||||
{
|
||||
$tenant = $this->createTenant('expired-lock');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'expired@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 5,
|
||||
'last_failed_login_at' => now()->subMinutes(20),
|
||||
'locked_until' => now()->subMinute(),
|
||||
])->save();
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'secret123',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertOk();
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame(0, $user->failed_login_attempts);
|
||||
$this->assertNull($user->locked_until);
|
||||
}
|
||||
|
||||
public function test_failures_outside_the_attempt_window_start_a_new_count(): void
|
||||
{
|
||||
config(['login-security.attempt_window_minutes' => 30]);
|
||||
|
||||
$tenant = $this->createTenant('attempt-window');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'window@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 4,
|
||||
'last_failed_login_at' => now()->subMinutes(31),
|
||||
])->save();
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertUnprocessable();
|
||||
|
||||
$this->assertSame(1, $user->refresh()->failed_login_attempts);
|
||||
$this->assertNull($user->locked_until);
|
||||
}
|
||||
|
||||
public function test_unknown_emails_are_audited_without_storing_the_email(): void
|
||||
{
|
||||
$tenant = $this->createTenant('unknown');
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'missing@example.com',
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertUnprocessable();
|
||||
|
||||
$attempt = LoginAttempt::query()->sole();
|
||||
$this->assertNull($attempt->user_id);
|
||||
$this->assertSame(LoginAttempt::OUTCOME_INVALID_CREDENTIALS, $attempt->outcome);
|
||||
$this->assertSame(64, strlen($attempt->email_fingerprint));
|
||||
$this->assertStringNotContainsString('missing@example.com', $attempt->email_fingerprint);
|
||||
}
|
||||
|
||||
public function test_login_is_rate_limited_by_email_and_ip(): void
|
||||
{
|
||||
config([
|
||||
'login-security.max_attempts' => 100,
|
||||
'login-security.rate_limit_per_minute' => 2,
|
||||
'login-security.ip_rate_limit_per_minute' => 100,
|
||||
]);
|
||||
|
||||
$tenant = $this->createTenant('rate-limit');
|
||||
$payload = [
|
||||
'email' => 'rate-limited@example.com',
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
];
|
||||
|
||||
$this->postJson('/api/login', $payload)->assertUnprocessable();
|
||||
$this->postJson('/api/login', $payload)->assertUnprocessable();
|
||||
$this->postJson('/api/login', $payload)
|
||||
->assertTooManyRequests()
|
||||
->assertHeader('Retry-After');
|
||||
|
||||
$this->assertDatabaseCount('login_attempts', 2);
|
||||
}
|
||||
|
||||
public function test_it_validates_required_login_fields(): void
|
||||
@@ -118,6 +301,34 @@ class LoginControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_localizes_success_and_validation_responses_from_accept_language(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
User::query()->create([
|
||||
'nombre_apellido' => 'Grace Hopper',
|
||||
'email' => 'grace@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
|
||||
$this->withHeader('Accept-Language', 'en-US,en;q=0.9')
|
||||
->postJson('/api/login', [
|
||||
'email' => 'grace@example.com',
|
||||
'password' => 'secret123',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])
|
||||
->assertOk()
|
||||
->assertHeader('Content-Language', 'en')
|
||||
->assertJsonPath('code', 'auth.login_success')
|
||||
->assertJsonPath('message', 'Signed in successfully.');
|
||||
|
||||
$this->withHeader('Accept-Language', 'en')
|
||||
->postJson('/api/login', [])
|
||||
->assertUnprocessable()
|
||||
->assertHeader('Content-Language', 'en')
|
||||
->assertJsonPath('errors.email.0', 'The email field is required.')
|
||||
->assertJsonPath('errors.password.0', 'The password field is required.');
|
||||
}
|
||||
|
||||
public function test_it_replaces_the_active_user_cart_with_the_guest_cart_on_login(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user