Compare commits
89 Commits
auth/googl
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| b294e5c46e | |||
| d02b0c5551 | |||
| ee911171be | |||
| e17d1fa15e | |||
| 2a15dc92be | |||
| 3a039a6055 | |||
| 84e45bb964 | |||
| 8aa3a26ee7 | |||
| 1de08c1ca4 | |||
| 3f9bcd84c4 | |||
| 96f26e2e9d | |||
| c4b317d5fc | |||
| 3b2977a330 | |||
| 3b36abd46d | |||
| 12697c2268 | |||
| f4638bc984 | |||
| c3713c62a6 | |||
| 4a51f3afde | |||
| f1df6a5918 | |||
| dbeaf19513 | |||
| dd76d5d951 | |||
| f603a82b5e | |||
| b478c23f3b | |||
| 6858b387c3 | |||
| 893bea1492 | |||
| ec1d80dff2 | |||
| 3968a10f6a | |||
| cf2beb2ff5 | |||
| 484fa204b3 | |||
| 6dd057874a | |||
| 40cbac17c6 | |||
| 45e56c43a6 | |||
| cbb7b1c3a1 | |||
| 6b3a12b624 | |||
| 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 | |||
| 5107d858cc | |||
| aa4f3303fc | |||
| 069867522a | |||
| 8a19b0136d | |||
| 8b26a2b63e | |||
| c37b8894e4 | |||
| 7b565e7fa7 | |||
| cb76d1c559 | |||
| 135a3bea3e | |||
| 9e6923e732 | |||
| 18c23a112a | |||
| c1be9ecf7f | |||
| 979ed428a2 | |||
| 7889d747d9 | |||
| f6ce85cbde | |||
| 460eec3c78 | |||
| 2b1ef5f8a9 | |||
| 5ada399adf | |||
| aed818da6e | |||
| 018f3f6c13 | |||
| 217f86f624 | |||
| 25891cefbc | |||
| c66d2019d6 | |||
| 12d496544d | |||
| c31be9c927 | |||
| 67ede1a2b4 | |||
| e778d862ba | |||
| 1438ff66c7 |
58
.env.example
58
.env.example
@@ -3,11 +3,15 @@ APP_ENV=production
|
|||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
FRONTEND_URL=http://localhost:4200
|
|
||||||
INTEGRATION_SECRET=
|
|
||||||
|
|
||||||
APP_LOCALE=en
|
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
|
||||||
APP_FALLBACK_LOCALE=en
|
PURCHASE_QR_EXPIRATION_MINUTES=15
|
||||||
|
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
|
||||||
|
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
|
||||||
|
FRONTEND_URLS=http://localhost:4200
|
||||||
|
|
||||||
|
APP_LOCALE=es
|
||||||
|
APP_FALLBACK_LOCALE=es
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
APP_MAINTENANCE_DRIVER=file
|
APP_MAINTENANCE_DRIVER=file
|
||||||
@@ -15,27 +19,38 @@ APP_MAINTENANCE_DRIVER=file
|
|||||||
|
|
||||||
# PHP_CLI_SERVER_WORKERS=4
|
# PHP_CLI_SERVER_WORKERS=4
|
||||||
|
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
GOOGLE_REDIRECT_URI=http://localhost/auth/google/callback
|
||||||
|
|
||||||
|
|
||||||
BCRYPT_ROUNDS=12
|
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_CHANNEL=stack
|
||||||
LOG_STACK=single
|
LOG_STACK=single
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
DB_CONNECTION=sqlite
|
DB_CONNECTION=mysql
|
||||||
# DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
# DB_PORT=3306
|
DB_PORT=3306
|
||||||
# DB_DATABASE=laravel
|
DB_DATABASE=laravel
|
||||||
# DB_USERNAME=root
|
DB_USERNAME=root
|
||||||
# DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
|
INTEGRATION_SECRET=change-me
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
|
SANCTUM_STATEFUL_DOMAINS=localhost,127.0.0.1,*.shopit.com.ar
|
||||||
SANCTUM_EXPIRATION=720
|
SANCTUM_EXPIRATION=720
|
||||||
|
SESSION_SECURE_COOKIE=false
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
@@ -51,27 +66,20 @@ REDIS_HOST=127.0.0.1
|
|||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
|
||||||
GOOGLE_CLIENT_ID=...
|
|
||||||
GOOGLE_CLIENT_SECRET=...
|
|
||||||
GOOGLE_REDIRECT_URI=https://grub-renewed-nicely.ngrok-free.app/auth/google/callback
|
|
||||||
|
|
||||||
MAIL_MAILER=smtp
|
MAIL_MAILER=smtp
|
||||||
MAIL_SCHEME=smtp
|
MAIL_SCHEME=null
|
||||||
MAIL_HOST=smtp.gmail.com
|
MAIL_HOST=127.0.0.1
|
||||||
MAIL_PORT=587
|
MAIL_PORT=2525
|
||||||
MAIL_USERNAME=null
|
MAIL_USERNAME=null
|
||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
|
||||||
AWS_ENDPOINT=
|
AWS_ENDPOINT=
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_DEFAULT_REGION=garage
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
AWS_BUCKET=
|
AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
AWS_HTTP_VERIFY=
|
AWS_HTTP_VERIFY=true
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
"response": []
|
"response": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Create Tenant (JSON)",
|
"name": "Create Tenant OnTicket (JSON)",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"header": [
|
"header": [
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"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": {
|
"url": {
|
||||||
"raw": "{{base_url}}/api/tenants",
|
"raw": "{{base_url}}/api/tenants",
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"response": []
|
"response": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Create Tenant (Form Data)",
|
"name": "Create Tenant ShopIt (Form Data)",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"header": [
|
"header": [
|
||||||
@@ -110,6 +110,11 @@
|
|||||||
"value": "#ff0000",
|
"value": "#ff0000",
|
||||||
"type": "text"
|
"type": "text"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "success_color",
|
||||||
|
"value": "#00aa55",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "header_bg_color",
|
"key": "header_bg_color",
|
||||||
"value": "#333333",
|
"value": "#333333",
|
||||||
@@ -129,6 +134,32 @@
|
|||||||
"key": "footer_logo",
|
"key": "footer_logo",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"src": []
|
"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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
42
app/Domains/Auth/Controllers/AdminAppLoginController.php
Normal file
42
app/Domains/Auth/Controllers/AdminAppLoginController.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Requests\AdminAppLoginRequest;
|
||||||
|
use App\Domains\Auth\Resources\UserResource;
|
||||||
|
use App\Domains\Auth\Services\PasswordLoginService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class AdminAppLoginController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PasswordLoginService $passwordLoginService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(AdminAppLoginRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$credentials = $request->validated();
|
||||||
|
$user = $this->passwordLoginService->authenticateAdminApp(
|
||||||
|
$credentials['email'],
|
||||||
|
$credentials['password'],
|
||||||
|
$request->ip(),
|
||||||
|
$request->userAgent(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$expirationMinutes = (int) config('sanctum.expiration');
|
||||||
|
$token = $user->createToken(
|
||||||
|
'adminapp-token',
|
||||||
|
['adminapp'],
|
||||||
|
now()->addMinutes($expirationMinutes),
|
||||||
|
)->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 'auth.login_success',
|
||||||
|
'message' => __('api.auth.login_success'),
|
||||||
|
'token' => $token,
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
'user' => UserResource::make($user),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Domains/Auth/Controllers/AdminAppMeController.php
Normal file
26
app/Domains/Auth/Controllers/AdminAppMeController.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Auth\Resources\AdminAppMeResource;
|
||||||
|
use App\Domains\Auth\Services\AdminAppContextService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AdminAppMeController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminAppContextService $adminAppContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): AdminAppMeResource
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
return AdminAppMeResource::make(
|
||||||
|
$this->adminAppContextService->load($user)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Requests\CreateResetPasswordAttemptRequest;
|
||||||
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class CreateResetPasswordAttemptController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(CreateResetPasswordAttemptRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
$this->resetPasswordAttemptService->createForEmail(
|
||||||
|
$data['email'],
|
||||||
|
$data['tenant_codigo'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 'auth.password_reset_requested',
|
||||||
|
'message' => __('api.auth.password_reset_requested'),
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
], 202);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,22 +5,47 @@ namespace App\Domains\Auth\Controllers;
|
|||||||
use App\Domains\Auth\Requests\GoogleTokenExchangeRequest;
|
use App\Domains\Auth\Requests\GoogleTokenExchangeRequest;
|
||||||
use App\Domains\Auth\Resources\UserResource;
|
use App\Domains\Auth\Resources\UserResource;
|
||||||
use App\Domains\Auth\Services\GoogleAuthService;
|
use App\Domains\Auth\Services\GoogleAuthService;
|
||||||
|
use App\Domains\Cart\Services\GuestCartMergeService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Cookie;
|
||||||
|
|
||||||
class GoogleTokenExchangeController extends Controller
|
class GoogleTokenExchangeController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private readonly GoogleAuthService $googleAuthService) {}
|
public function __construct(
|
||||||
|
private readonly GoogleAuthService $googleAuthService,
|
||||||
|
private readonly GuestCartMergeService $guestCartMergeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function __invoke(GoogleTokenExchangeRequest $request): JsonResponse
|
public function __invoke(GoogleTokenExchangeRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$authentication = $this->googleAuthService->exchange($request->validated('oauth_code'));
|
$authentication = $this->googleAuthService->exchange(
|
||||||
|
$request->validated('oauth_code'),
|
||||||
|
$request->validated('tenant_codigo'),
|
||||||
|
);
|
||||||
|
|
||||||
return response()->json([
|
$guestTokenCookie = $request->cookie('guest_token');
|
||||||
'message' => 'Sesion iniciada correctamente.',
|
$guestToken = is_string($guestTokenCookie) && $guestTokenCookie !== ''
|
||||||
|
? $guestTokenCookie
|
||||||
|
: null;
|
||||||
|
$this->guestCartMergeService->merge(
|
||||||
|
$authentication['tenant_codigo'],
|
||||||
|
$authentication['user'],
|
||||||
|
$guestToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = response()->json([
|
||||||
|
'code' => 'auth.login_success',
|
||||||
|
'message' => __('api.auth.login_success'),
|
||||||
'token' => $authentication['token'],
|
'token' => $authentication['token'],
|
||||||
'token_type' => 'Bearer',
|
'token_type' => 'Bearer',
|
||||||
'user' => UserResource::make($authentication['user']),
|
'user' => UserResource::make($authentication['user']),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($guestToken !== null) {
|
||||||
|
$response->withCookie(Cookie::forget('guest_token'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,28 +3,30 @@
|
|||||||
namespace App\Domains\Auth\Controllers;
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
use App\Domains\Auth\Requests\LoginUserRequest;
|
use App\Domains\Auth\Requests\LoginUserRequest;
|
||||||
use App\Domains\Auth\Models\User;
|
|
||||||
use App\Domains\Auth\Resources\UserResource;
|
use App\Domains\Auth\Resources\UserResource;
|
||||||
|
use App\Domains\Auth\Services\PasswordLoginService;
|
||||||
|
use App\Domains\Cart\Services\GuestCartMergeService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Cookie;
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class LoginController extends Controller
|
class LoginController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
public function __construct(
|
||||||
* @throws ValidationException
|
private readonly GuestCartMergeService $guestCartMergeService,
|
||||||
*/
|
private readonly PasswordLoginService $passwordLoginService,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function __invoke(LoginUserRequest $request): JsonResponse
|
public function __invoke(LoginUserRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$credentials = $request->validated();
|
$credentials = $request->validated();
|
||||||
$user = User::query()->where('email', $credentials['email'])->first();
|
$user = $this->passwordLoginService->authenticate(
|
||||||
|
$credentials['email'],
|
||||||
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
|
$credentials['password'],
|
||||||
throw ValidationException::withMessages([
|
$credentials['tenant_codigo'],
|
||||||
'email' => 'Email o Contraseña incorrectos.',
|
$request->ip(),
|
||||||
]);
|
$request->userAgent(),
|
||||||
}
|
);
|
||||||
|
|
||||||
$expirationMinutes = (int) config('sanctum.expiration');
|
$expirationMinutes = (int) config('sanctum.expiration');
|
||||||
$token = $user->createToken(
|
$token = $user->createToken(
|
||||||
@@ -33,11 +35,28 @@ class LoginController extends Controller
|
|||||||
now()->addMinutes($expirationMinutes),
|
now()->addMinutes($expirationMinutes),
|
||||||
)->plainTextToken;
|
)->plainTextToken;
|
||||||
|
|
||||||
return response()->json([
|
$guestTokenCookie = $request->cookie('guest_token');
|
||||||
'message' => 'Sesion iniciada correctamente.',
|
$guestToken = is_string($guestTokenCookie) && $guestTokenCookie !== ''
|
||||||
|
? $guestTokenCookie
|
||||||
|
: null;
|
||||||
|
$this->guestCartMergeService->merge(
|
||||||
|
$credentials['tenant_codigo'],
|
||||||
|
$user,
|
||||||
|
$guestToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = response()->json([
|
||||||
|
'code' => 'auth.login_success',
|
||||||
|
'message' => __('api.auth.login_success'),
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
'token_type' => 'Bearer',
|
'token_type' => 'Bearer',
|
||||||
'user' => UserResource::make($user),
|
'user' => UserResource::make($user),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($guestToken !== null) {
|
||||||
|
$response->withCookie(Cookie::forget('guest_token'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ class LogoutController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
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(
|
public function __construct(
|
||||||
protected RegisterUserService $registerUserService,
|
protected RegisterUserService $registerUserService,
|
||||||
) {
|
) {}
|
||||||
}
|
|
||||||
|
|
||||||
public function __invoke(RegisterUserRequest $request): JsonResponse
|
public function __invoke(RegisterUserRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $this->registerUserService->register($request->validated());
|
$user = $this->registerUserService->register($request->validated());
|
||||||
|
|
||||||
return UserResource::make($user)
|
return UserResource::make($user)
|
||||||
->additional(['message' => 'Usuario registrado correctamente.'])
|
->additional([
|
||||||
|
'code' => 'auth.register_success',
|
||||||
|
'message' => __('api.auth.register_success'),
|
||||||
|
])
|
||||||
->response()
|
->response()
|
||||||
->setStatusCode(201);
|
->setStatusCode(201);
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/Domains/Auth/Controllers/ResetPasswordController.php
Normal file
41
app/Domains/Auth/Controllers/ResetPasswordController.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Requests\ResetPasswordRequest;
|
||||||
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class ResetPasswordController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function __invoke(ResetPasswordRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
if (! $this->resetPasswordAttemptService->resetPassword(
|
||||||
|
$data['email'],
|
||||||
|
$data['codigo'],
|
||||||
|
$data['password'],
|
||||||
|
)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'codigo' => __('api.auth.password_reset_invalid'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 'auth.password_updated',
|
||||||
|
'message' => __('api.auth.password_updated'),
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_USED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest;
|
||||||
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class ValidateResetPasswordAttemptController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function __invoke(ValidateResetPasswordAttemptRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
if (! $this->resetPasswordAttemptService->validateCode(
|
||||||
|
$data['email'],
|
||||||
|
$data['codigo'],
|
||||||
|
)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'codigo' => __('api.auth.reset_code_invalid'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Domains/Auth/Models/ResetPasswordAttempt.php
Normal file
36
app/Domains/Auth/Models/ResetPasswordAttempt.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
|
||||||
|
#[Hidden(['codigo'])]
|
||||||
|
class ResetPasswordAttempt extends Model
|
||||||
|
{
|
||||||
|
public const STATUS_PENDING = 'pending';
|
||||||
|
|
||||||
|
public const STATUS_VALIDATED = 'validated';
|
||||||
|
|
||||||
|
public const STATUS_USED = 'used';
|
||||||
|
|
||||||
|
public const STATUS_EXPIRED = 'expired';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<User, $this> */
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,26 +2,76 @@
|
|||||||
|
|
||||||
namespace App\Domains\Auth\Models;
|
namespace App\Domains\Auth\Models;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Authorization\Models\Role;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
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'])]
|
#[Hidden(['password', 'remember_token'])]
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
use HasApiTokens, HasFactory, Notifiable;
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'rol_codigo' => RoleCode::User->value,
|
||||||
|
];
|
||||||
|
|
||||||
protected static function newFactory(): UserFactory
|
protected static function newFactory(): UserFactory
|
||||||
{
|
{
|
||||||
return UserFactory::new();
|
return UserFactory::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<ResetPasswordAttempt, $this> */
|
||||||
|
public function resetPasswordAttempts(): HasMany
|
||||||
|
{
|
||||||
|
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 BelongsToMany<Category, $this> */
|
||||||
|
public function scanCategories(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(
|
||||||
|
Category::class,
|
||||||
|
'category_scanners',
|
||||||
|
'user_id',
|
||||||
|
'categoria_id',
|
||||||
|
)->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
@@ -30,6 +80,9 @@ class User extends Authenticatable
|
|||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
|
'failed_login_attempts' => 'integer',
|
||||||
|
'last_failed_login_at' => 'datetime',
|
||||||
|
'locked_until' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
app/Domains/Auth/Requests/AdminAppLoginRequest.php
Normal file
36
app/Domains/Auth/Requests/AdminAppLoginRequest.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class AdminAppLoginRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
|
||||||
|
if (is_string($email)) {
|
||||||
|
$this->merge([
|
||||||
|
'email' => Str::lower(trim($email)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class CreateResetPasswordAttemptRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
|
||||||
|
if (is_string($email)) {
|
||||||
|
$this->merge([
|
||||||
|
'email' => Str::lower(trim($email)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenant_codigo' => ['required', 'string', Rule::exists('tenants', 'codigo')],
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ class GoogleTokenExchangeRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'oauth_code' => ['required', 'uuid'],
|
'oauth_code' => ['required', 'uuid'],
|
||||||
|
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Auth\Requests;
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class LoginUserRequest extends FormRequest
|
class LoginUserRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -11,6 +12,17 @@ class LoginUserRequest extends FormRequest
|
|||||||
return true;
|
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>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -19,6 +31,7 @@ class LoginUserRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'email' => ['required', 'string', 'email', 'max:255'],
|
'email' => ['required', 'string', 'email', 'max:255'],
|
||||||
'password' => ['required', 'string'],
|
'password' => ['required', 'string'],
|
||||||
|
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
app/Domains/Auth/Requests/ResetPasswordRequest.php
Normal file
43
app/Domains/Auth/Requests/ResetPasswordRequest.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
class ResetPasswordRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
|
||||||
|
if (is_string($email)) {
|
||||||
|
$this->merge([
|
||||||
|
'email' => Str::lower(trim($email)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255'],
|
||||||
|
'codigo' => ['required', 'string', 'regex:/^\d{4}$/'],
|
||||||
|
'password' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'confirmed',
|
||||||
|
Password::min(8)->mixedCase()->symbols(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ValidateResetPasswordAttemptRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
|
||||||
|
if (is_string($email)) {
|
||||||
|
$this->merge([
|
||||||
|
'email' => Str::lower(trim($email)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255'],
|
||||||
|
'codigo' => ['required', 'string', 'regex:/^\d{4}$/'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Domains/Auth/Resources/AdminAppMeResource.php
Normal file
25
app/Domains/Auth/Resources/AdminAppMeResource.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Tenant\Resources\TenantResource;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin User
|
||||||
|
*/
|
||||||
|
class AdminAppMeResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user' => UserResource::make($this->resource),
|
||||||
|
'tenant' => TenantResource::make($this->tenant),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Domains\Auth\Resources;
|
namespace App\Domains\Auth\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @mixin \App\Domains\Auth\Models\User
|
* @mixin User
|
||||||
*/
|
*/
|
||||||
class UserResource extends JsonResource
|
class UserResource extends JsonResource
|
||||||
{
|
{
|
||||||
@@ -21,6 +22,8 @@ class UserResource extends JsonResource
|
|||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
'dni' => $this->dni,
|
'dni' => $this->dni,
|
||||||
'telefono' => $this->telefono,
|
'telefono' => $this->telefono,
|
||||||
|
'rol_codigo' => $this->rol_codigo,
|
||||||
|
'tenant_codigo' => $this->tenant_codigo,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
app/Domains/Auth/Services/AdminAppContextService.php
Normal file
26
app/Domains/Auth/Services/AdminAppContextService.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
|
||||||
|
class AdminAppContextService
|
||||||
|
{
|
||||||
|
public function load(User $user): User
|
||||||
|
{
|
||||||
|
$tenant = $user->tenant()
|
||||||
|
->with([
|
||||||
|
'menues' => fn ($query) => $query
|
||||||
|
->whereHas(
|
||||||
|
'roles',
|
||||||
|
fn ($query) => $query->where('codigo', RoleCode::AdminApp->value)
|
||||||
|
),
|
||||||
|
])
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$user->setRelation('tenant', $tenant);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ class GoogleAuthService
|
|||||||
|
|
||||||
if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) {
|
if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) {
|
||||||
throw ValidationException::withMessages([
|
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;
|
$context = Str::isUuid($state) ? Cache::pull("google-oauth-context:{$state}") : null;
|
||||||
|
|
||||||
if (! is_array($context) || ! isset($context['tenant_codigo'], $context['return_url'])) {
|
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();
|
$tenant = Tenant::query()->where('codigo', $context['tenant_codigo'])->first();
|
||||||
|
|
||||||
if (! $tenant || ! $this->isTenantReturnUrl($context['return_url'], $tenant)) {
|
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 */
|
/** @var SocialiteUser $googleUser */
|
||||||
@@ -72,6 +72,7 @@ class GoogleAuthService
|
|||||||
Cache::put("google-oauth-exchange:{$exchangeCode}", [
|
Cache::put("google-oauth-exchange:{$exchangeCode}", [
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
], now()->addMinutes(5));
|
], now()->addMinutes(5));
|
||||||
|
|
||||||
return redirect()->to($context['return_url'].'/login?'.http_build_query([
|
return redirect()->to($context['return_url'].'/login?'.http_build_query([
|
||||||
@@ -79,21 +80,28 @@ class GoogleAuthService
|
|||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array{user: User, token: string} */
|
/** @return array{user: User, token: string, tenant_codigo: string} */
|
||||||
public function exchange(string $exchangeCode): array
|
public function exchange(string $exchangeCode, string $tenantCodigo): array
|
||||||
{
|
{
|
||||||
/** @var array{user_id: int, token: string}|null $authentication */
|
/** @var array{user_id: int, token: string, tenant_codigo?: string}|null $authentication */
|
||||||
$authentication = Cache::pull("google-oauth-exchange:{$exchangeCode}");
|
$authentication = Cache::pull("google-oauth-exchange:{$exchangeCode}");
|
||||||
|
|
||||||
if (! $authentication) {
|
if (! $authentication) {
|
||||||
throw ValidationException::withMessages([
|
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' => __('api.auth.tenant_mismatch'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'user' => User::query()->findOrFail($authentication['user_id']),
|
'user' => User::query()->findOrFail($authentication['user_id']),
|
||||||
'token' => $authentication['token'],
|
'token' => $authentication['token'],
|
||||||
|
'tenant_codigo' => $tenantCodigo,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +112,7 @@ class GoogleAuthService
|
|||||||
|
|
||||||
if (! is_string($googleId) || $googleId === '' || ! is_string($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
if (! is_string($googleId) || $googleId === '' || ! is_string($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'google' => 'Google no devolvio una identidad valida.',
|
'google' => __('api.auth.google_email_required'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +120,7 @@ class GoogleAuthService
|
|||||||
$emailVerified = $rawUser['email_verified'] ?? $rawUser['verified_email'] ?? false;
|
$emailVerified = $rawUser['email_verified'] ?? $rawUser['verified_email'] ?? false;
|
||||||
if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) {
|
if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'google' => 'La cuenta de Google debe tener el email verificado.',
|
'google' => __('api.auth.google_email_unverified'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
242
app/Domains/Auth/Services/PasswordLoginService.php
Normal file
242
app/Domains/Auth/Services/PasswordLoginService.php
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<?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 App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class PasswordLoginService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AccountLockedException
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function authenticate(
|
||||||
|
string $email,
|
||||||
|
string $password,
|
||||||
|
string $tenantCode,
|
||||||
|
?string $ipAddress,
|
||||||
|
?string $userAgent,
|
||||||
|
): User {
|
||||||
|
return $this->authenticateUser(
|
||||||
|
$email,
|
||||||
|
$password,
|
||||||
|
$tenantCode,
|
||||||
|
$ipAddress,
|
||||||
|
$userAgent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate a tenant-bound AdminApp user without requiring the caller
|
||||||
|
* to know their tenant code beforehand.
|
||||||
|
*
|
||||||
|
* @throws AccountLockedException
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function authenticateAdminApp(
|
||||||
|
string $email,
|
||||||
|
string $password,
|
||||||
|
?string $ipAddress,
|
||||||
|
?string $userAgent,
|
||||||
|
): User {
|
||||||
|
return $this->authenticateUser(
|
||||||
|
$email,
|
||||||
|
$password,
|
||||||
|
null,
|
||||||
|
$ipAddress,
|
||||||
|
$userAgent,
|
||||||
|
RoleCode::AdminApp,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authenticateUser(
|
||||||
|
string $email,
|
||||||
|
string $password,
|
||||||
|
?string $tenantCode,
|
||||||
|
?string $ipAddress,
|
||||||
|
?string $userAgent,
|
||||||
|
RoleCode $requiredRole = RoleCode::User,
|
||||||
|
bool $requiresTenant = false,
|
||||||
|
): User {
|
||||||
|
$normalizedEmail = mb_strtolower(trim($email));
|
||||||
|
$now = CarbonImmutable::now();
|
||||||
|
|
||||||
|
/** @var array{outcome: string, user: User|null, locked_until: CarbonImmutable|null} $result */
|
||||||
|
$result = DB::transaction(function () use (
|
||||||
|
$normalizedEmail,
|
||||||
|
$password,
|
||||||
|
$tenantCode,
|
||||||
|
$ipAddress,
|
||||||
|
$userAgent,
|
||||||
|
$now,
|
||||||
|
$requiredRole,
|
||||||
|
$requiresTenant,
|
||||||
|
): array {
|
||||||
|
$user = User::query()
|
||||||
|
->where('email', $normalizedEmail)
|
||||||
|
->where('rol_codigo', $requiredRole->value)
|
||||||
|
->when(
|
||||||
|
$requiresTenant,
|
||||||
|
fn ($query) => $query->whereNotNull('tenant_codigo'),
|
||||||
|
)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
$attemptTenantCode = $tenantCode ?? $user?->tenant_codigo;
|
||||||
|
|
||||||
|
if ($user?->locked_until?->isFuture()) {
|
||||||
|
$this->recordAttempt(
|
||||||
|
$user,
|
||||||
|
$normalizedEmail,
|
||||||
|
$attemptTenantCode,
|
||||||
|
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, $tenantCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
$outcome = $user?->locked_until?->isFuture()
|
||||||
|
? LoginAttempt::OUTCOME_ACCOUNT_LOCKED
|
||||||
|
: LoginAttempt::OUTCOME_INVALID_CREDENTIALS;
|
||||||
|
$this->recordAttempt(
|
||||||
|
$user,
|
||||||
|
$normalizedEmail,
|
||||||
|
$attemptTenantCode,
|
||||||
|
$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,
|
||||||
|
$attemptTenantCode,
|
||||||
|
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, string $tenantCode): 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;
|
||||||
|
|
||||||
|
$previousAttempts = $user->failed_login_attempts;
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'failed_login_attempts' => $attempts,
|
||||||
|
'last_failed_login_at' => $now,
|
||||||
|
'locked_until' => $attempts >= $maxAttempts
|
||||||
|
? $now->addMinutes($lockMinutes)
|
||||||
|
: null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
if ($attempts >= $maxAttempts && $previousAttempts < $maxAttempts) {
|
||||||
|
try {
|
||||||
|
$this->resetPasswordAttemptService->createForEmail($user->email, $tenantCode, 'account_locked');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Failed to trigger reset password on account lock', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'exception' => $e
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
app/Domains/Auth/Services/ResetPasswordAttemptService.php
Normal file
181
app/Domains/Auth/Services/ResetPasswordAttemptService.php
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class ResetPasswordAttemptService
|
||||||
|
{
|
||||||
|
public function createForEmail(string $email, string $tenantCode, string $reason = 'manual'): void
|
||||||
|
{
|
||||||
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
|
||||||
|
$user = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
Log::warning('Password reset attempt was not created because the user was not found.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->resetPasswordAttempts()
|
||||||
|
->whereIn('status', [
|
||||||
|
ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
])
|
||||||
|
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
|
||||||
|
|
||||||
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => $this->generateCode(),
|
||||||
|
'reason' => $reason,
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $attempt->getKey();
|
||||||
|
});
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Failed to create password reset attempt.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attemptId !== null) {
|
||||||
|
try {
|
||||||
|
PasswordResetRequested::dispatch($attemptId, $tenantCode);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Failed to dispatch password reset email.', [
|
||||||
|
'attempt_id' => $attemptId,
|
||||||
|
'tenant_code' => $tenantCode,
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateCode(string $email, string $code): bool
|
||||||
|
{
|
||||||
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool {
|
||||||
|
$user = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$attempt = $user?->resetPasswordAttempts()
|
||||||
|
->where('codigo', $code)
|
||||||
|
->where('status', ResetPasswordAttempt::STATUS_PENDING)
|
||||||
|
->latest('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($attempt === null) {
|
||||||
|
Log::warning('Password reset code validation failed: no matching pending attempt.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attempt->update([
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Failed to validate password reset code.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetPassword(string $email, string $code, string $password): bool
|
||||||
|
{
|
||||||
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint): bool {
|
||||||
|
$user = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$attempt = $user?->resetPasswordAttempts()
|
||||||
|
->where('codigo', $code)
|
||||||
|
->where('status', ResetPasswordAttempt::STATUS_VALIDATED)
|
||||||
|
->latest('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($user === null || $attempt === null) {
|
||||||
|
Log::warning('Password reset failed: no matching validated attempt.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->password = $password;
|
||||||
|
$user->failed_login_attempts = 0;
|
||||||
|
$user->last_failed_login_at = null;
|
||||||
|
$user->locked_until = null;
|
||||||
|
$user->save();
|
||||||
|
$user->tokens()->delete();
|
||||||
|
|
||||||
|
$user->resetPasswordAttempts()
|
||||||
|
->whereKeyNot($attempt->getKey())
|
||||||
|
->whereIn('status', [
|
||||||
|
ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
])
|
||||||
|
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
|
||||||
|
|
||||||
|
$attempt->update([
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_USED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Failed to reset user password.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateCode(): string
|
||||||
|
{
|
||||||
|
return str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function emailFingerprint(string $email): string
|
||||||
|
{
|
||||||
|
return substr(hash('sha256', strtolower(trim($email))), 0, 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/Domains/Auth/routes/adminapp.php
Normal file
11
app/Domains/Auth/routes/adminapp.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Auth\Controllers\AdminAppLoginController;
|
||||||
|
use App\Domains\Auth\Controllers\AdminAppMeController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp')->group(function (): void {
|
||||||
|
Route::post('login', AdminAppLoginController::class)->middleware('throttle:login');
|
||||||
|
Route::middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->get('me', AdminAppMeController::class);
|
||||||
|
});
|
||||||
@@ -1,16 +1,27 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Auth\Controllers\CreateResetPasswordAttemptController;
|
||||||
use App\Domains\Auth\Controllers\GoogleTokenExchangeController;
|
use App\Domains\Auth\Controllers\GoogleTokenExchangeController;
|
||||||
use App\Domains\Auth\Controllers\LoginController;
|
use App\Domains\Auth\Controllers\LoginController;
|
||||||
use App\Domains\Auth\Controllers\LogoutController;
|
use App\Domains\Auth\Controllers\LogoutController;
|
||||||
use App\Domains\Auth\Controllers\MeController;
|
use App\Domains\Auth\Controllers\MeController;
|
||||||
use App\Domains\Auth\Controllers\RegisterController;
|
use App\Domains\Auth\Controllers\RegisterController;
|
||||||
|
use App\Domains\Auth\Controllers\ResetPasswordController;
|
||||||
use App\Domains\Auth\Controllers\UpdateProfileController;
|
use App\Domains\Auth\Controllers\UpdateProfileController;
|
||||||
|
use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::post('/register', RegisterController::class);
|
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)
|
||||||
|
->middleware('throttle:10,1');
|
||||||
|
Route::post('/password/reset', ResetPasswordController::class)
|
||||||
|
->middleware('throttle:5,1');
|
||||||
Route::post('/auth/google/exchange', GoogleTokenExchangeController::class);
|
Route::post('/auth/google/exchange', GoogleTokenExchangeController::class);
|
||||||
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
|
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
|
||||||
Route::middleware('auth:sanctum')->get('/me', MeController::class);
|
Route::middleware('auth:sanctum')->get('/me', MeController::class);
|
||||||
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
|
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
|
||||||
|
|
||||||
|
require __DIR__.'/adminapp.php';
|
||||||
|
|||||||
11
app/Domains/Authorization/Enums/RoleCode.php
Normal file
11
app/Domains/Authorization/Enums/RoleCode.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Authorization\Enums;
|
||||||
|
|
||||||
|
enum RoleCode: string
|
||||||
|
{
|
||||||
|
case Admin = 'admin';
|
||||||
|
case AdminApp = 'adminapp';
|
||||||
|
case Scanner = 'scanner';
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Bootstrap\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Bootstrap\Requests\AdminAppBootstrapRequest;
|
||||||
|
use App\Domains\Bootstrap\Resources\AdminAppBootstrapResource;
|
||||||
|
use App\Domains\Bootstrap\Services\AdminAppBootstrapService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
class AdminAppBootstrapController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected AdminAppBootstrapService $bootstrapService) {}
|
||||||
|
|
||||||
|
public function __invoke(AdminAppBootstrapRequest $request): AdminAppBootstrapResource
|
||||||
|
{
|
||||||
|
return AdminAppBootstrapResource::make(
|
||||||
|
$this->bootstrapService->get((string) $request->validated('dominio'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Bootstrap\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Bootstrap\Requests\TenantBootstrapRequest;
|
||||||
|
use App\Domains\Bootstrap\Services\TenantBootstrapService;
|
||||||
|
use App\Domains\Tenant\Resources\TenantResource;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
class TenantBootstrapController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected TenantBootstrapService $bootstrapService) {}
|
||||||
|
|
||||||
|
public function __invoke(TenantBootstrapRequest $request): TenantResource
|
||||||
|
{
|
||||||
|
return TenantResource::make(
|
||||||
|
$this->bootstrapService->get((string) $request->validated('dominio'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Bootstrap\Requests;
|
||||||
|
|
||||||
|
class AdminAppBootstrapRequest extends TenantBootstrapRequest {}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Requests;
|
namespace App\Domains\Bootstrap\Requests;
|
||||||
|
|
||||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class BootstrapTenantRequest extends FormRequest
|
class TenantBootstrapRequest extends FormRequest
|
||||||
{
|
{
|
||||||
protected bool $hasInvalidDomain = false;
|
protected bool $hasInvalidDomain = false;
|
||||||
|
|
||||||
@@ -23,14 +23,10 @@ class BootstrapTenantRequest extends FormRequest
|
|||||||
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
||||||
&& $normalizedDomain === null;
|
&& $normalizedDomain === null;
|
||||||
|
|
||||||
$this->merge([
|
$this->merge(['dominio' => $normalizedDomain]);
|
||||||
'dominio' => $normalizedDomain,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @return array<string, mixed> */
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Bootstrap\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin array{website_type: WebsiteType} */
|
||||||
|
class AdminAppBootstrapResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
/** @var WebsiteType $websiteType */
|
||||||
|
$websiteType = $this->resource['website_type'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'website_type_code' => $websiteType->codigo,
|
||||||
|
'primary_color' => $websiteType->primary_color,
|
||||||
|
'secondary_color' => $websiteType->secondary_color,
|
||||||
|
'danger_color' => $websiteType->danger_color,
|
||||||
|
'success_color' => $websiteType->success_color,
|
||||||
|
'warning_color' => $websiteType->warning_color,
|
||||||
|
'body_color' => $websiteType->body_color,
|
||||||
|
'darker_body_color' => $websiteType->darker_body_color,
|
||||||
|
'surface_color' => $websiteType->surface_color,
|
||||||
|
'background_color' => $websiteType->background_color,
|
||||||
|
'border_color' => $websiteType->border_color,
|
||||||
|
'login_header_footer_color' => $websiteType->login_header_footer_color,
|
||||||
|
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
|
||||||
|
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Domains/Bootstrap/Services/AdminAppBootstrapService.php
Normal file
19
app/Domains/Bootstrap/Services/AdminAppBootstrapService.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Bootstrap\Services;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
|
||||||
|
class AdminAppBootstrapService
|
||||||
|
{
|
||||||
|
/** @return array{website_type: WebsiteType} */
|
||||||
|
public function get(string $domain): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'website_type' => WebsiteType::query()
|
||||||
|
->with(['siteLogo', 'footerLogo'])
|
||||||
|
->where('dominio', $domain)
|
||||||
|
->firstOrFail(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Domains/Bootstrap/Services/TenantBootstrapService.php
Normal file
26
app/Domains/Bootstrap/Services/TenantBootstrapService.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Bootstrap\Services;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Services\TenantInformationService;
|
||||||
|
|
||||||
|
class TenantBootstrapService
|
||||||
|
{
|
||||||
|
public function __construct(protected TenantInformationService $tenantInformationService) {}
|
||||||
|
|
||||||
|
public function get(string $domain): Tenant
|
||||||
|
{
|
||||||
|
return $this->tenantInformationService->load(
|
||||||
|
Tenant::query()->where('dominio', $domain)->firstOrFail(),
|
||||||
|
[
|
||||||
|
'menues' => fn ($query) => $query->whereHas(
|
||||||
|
'roles',
|
||||||
|
fn ($query) => $query->where('codigo', RoleCode::User->value)
|
||||||
|
),
|
||||||
|
'categories' => fn ($query) => $query->orderBy('nombre'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
app/Domains/Bootstrap/routes/adminapp.php
Normal file
9
app/Domains/Bootstrap/routes/adminapp.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Bootstrap\Controllers\AdminAppBootstrapController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::get(
|
||||||
|
'v1/adminapp/bootstrap/{dominio}',
|
||||||
|
AdminAppBootstrapController::class
|
||||||
|
);
|
||||||
9
app/Domains/Bootstrap/routes/api.php
Normal file
9
app/Domains/Bootstrap/routes/api.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Bootstrap\Controllers\TenantBootstrapController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::get('tenants/bootstrap/{dominio}', TenantBootstrapController::class)
|
||||||
|
->where('dominio', '.*');
|
||||||
|
|
||||||
|
require __DIR__.'/adminapp.php';
|
||||||
@@ -36,7 +36,10 @@ class CartController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
$response = CartResource::make($result['cart'])
|
$response = CartResource::make($result['cart'])
|
||||||
->additional(['message' => 'Producto agregado al carrito.'])
|
->additional([
|
||||||
|
'code' => 'cart.item_added',
|
||||||
|
'message' => __('api.cart.item_added'),
|
||||||
|
])
|
||||||
->response();
|
->response();
|
||||||
|
|
||||||
if ($result['guest_token'] !== null) {
|
if ($result['guest_token'] !== null) {
|
||||||
@@ -58,13 +61,19 @@ class CartController extends Controller
|
|||||||
$cartItem->getKey(),
|
$cartItem->getKey(),
|
||||||
(int) $request->validated('cantidad'),
|
(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
|
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
|
||||||
{
|
{
|
||||||
return CartResource::make(
|
return CartResource::make(
|
||||||
$this->cartService->removeItem($tenant, $request, $cartItem->getKey())
|
$this->cartService->removeItem($tenant, $request, $cartItem->getKey())
|
||||||
)->additional(['message' => 'Producto eliminado del carrito.']);
|
)->additional([
|
||||||
|
'code' => 'cart.item_removed',
|
||||||
|
'message' => __('api.cart.item_removed'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Cart\Middleware;
|
|
||||||
|
|
||||||
use App\Domains\Cart\Models\Cart;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Cookie;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class MergeGuestCartMiddleware
|
|
||||||
{
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
|
||||||
{
|
|
||||||
$guestToken = $request->cookie('guest_token');
|
|
||||||
|
|
||||||
// Resolve authenticated user optionally (from default guard or sanctum guard)
|
|
||||||
$user = $request->user() ?? Auth::guard('sanctum')->user();
|
|
||||||
$userId = $user?->getKey();
|
|
||||||
|
|
||||||
\Illuminate\Support\Facades\Log::info('MergeGuestCartMiddleware processed', [
|
|
||||||
'user_id' => $userId,
|
|
||||||
'guest_token' => $guestToken,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($user !== null && is_string($guestToken) && $guestToken !== '') {
|
|
||||||
$tenantParam = $request->route('tenant');
|
|
||||||
$tenantCodigo = null;
|
|
||||||
|
|
||||||
if ($tenantParam instanceof Tenant) {
|
|
||||||
$tenantCodigo = $tenantParam->codigo;
|
|
||||||
} elseif (is_string($tenantParam)) {
|
|
||||||
$tenantCodigo = $tenantParam;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenantCodigo !== null) {
|
|
||||||
$guestCart = Cart::query()
|
|
||||||
->where('tenant_codigo', $tenantCodigo)
|
|
||||||
->where('guest_token', $guestToken)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($guestCart !== null && $guestCart->items()->exists()) {
|
|
||||||
$userCart = Cart::query()
|
|
||||||
->where('tenant_codigo', $tenantCodigo)
|
|
||||||
->where('user_id', $userId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($userCart !== null) {
|
|
||||||
$userCart->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
$guestCart->update([
|
|
||||||
'user_id' => $userId,
|
|
||||||
'guest_token' => null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = $next($request);
|
|
||||||
|
|
||||||
if ($user !== null && is_string($guestToken) && $guestToken !== '') {
|
|
||||||
// Remove the cookie from the response since the user is authenticated.
|
|
||||||
if (method_exists($response, 'withCookie')) {
|
|
||||||
$response->withCookie(Cookie::forget('guest_token'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -79,7 +79,7 @@ class Cart extends Model
|
|||||||
{
|
{
|
||||||
if ($quantity <= 0) {
|
if ($quantity <= 0) {
|
||||||
throw ValidationException::withMessages([
|
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) {
|
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||||
throw ValidationException::withMessages([
|
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) {
|
if ($quantity <= 0) {
|
||||||
throw ValidationException::withMessages([
|
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) {
|
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
||||||
$maxAvailable = $availableQuantity + $item->cantidad;
|
$maxAvailable = $availableQuantity + $item->cantidad;
|
||||||
throw ValidationException::withMessages([
|
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 ($catalogItem->isBundle()) {
|
||||||
if ($variantId !== null) {
|
if ($variantId !== null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'variant_id' => 'Un bundle no admite una variante.',
|
'variant_id' => __('api.cart.bundle_variant_forbidden'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $catalogItem->bundleComponents()->exists()) {
|
if (! $catalogItem->bundleComponents()->exists()) {
|
||||||
throw ValidationException::withMessages([
|
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 ($variantId === null) {
|
||||||
if ($catalogItem->inventory_id === null) {
|
if ($catalogItem->inventory_id === null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'variant_id' => 'Debe seleccionar una variante para este ítem.',
|
'variant_id' => __('api.cart.variant_required'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ class CartService
|
|||||||
{
|
{
|
||||||
return Cart::query()
|
return Cart::query()
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->where('status', 'active')
|
||||||
->when(
|
->when(
|
||||||
$identity['user_id'] !== null,
|
$identity['user_id'] !== null,
|
||||||
fn ($query) => $query->where('user_id', $identity['user_id']),
|
fn ($query) => $query->where('user_id', $identity['user_id']),
|
||||||
@@ -199,7 +200,10 @@ class CartService
|
|||||||
*/
|
*/
|
||||||
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
|
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
|
||||||
{
|
{
|
||||||
$attributes = ['tenant_codigo' => $tenant->codigo];
|
$attributes = [
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
'status' => 'active',
|
||||||
|
];
|
||||||
|
|
||||||
if ($identity['user_id'] !== null) {
|
if ($identity['user_id'] !== null) {
|
||||||
$attributes['user_id'] = $identity['user_id'];
|
$attributes['user_id'] = $identity['user_id'];
|
||||||
@@ -207,6 +211,6 @@ class CartService
|
|||||||
$attributes['guest_token'] = $identity['guest_token'];
|
$attributes['guest_token'] = $identity['guest_token'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return Cart::query()->firstOrCreate($attributes, ['status' => 'active']);
|
return Cart::query()->firstOrCreate($attributes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
app/Domains/Cart/Services/GuestCartMergeService.php
Normal file
49
app/Domains/Cart/Services/GuestCartMergeService.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Cart\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Cart\Models\Cart;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class GuestCartMergeService
|
||||||
|
{
|
||||||
|
public function merge(string $tenantCodigo, User $user, ?string $guestToken): void
|
||||||
|
{
|
||||||
|
if ($guestToken === null || $guestToken === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($tenantCodigo, $user, $guestToken): void {
|
||||||
|
$guestCart = Cart::query()
|
||||||
|
->where('tenant_codigo', $tenantCodigo)
|
||||||
|
->where('guest_token', $guestToken)
|
||||||
|
->where('status', 'active')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($guestCart === null || ! $guestCart->items()->exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userCart = Cart::query()
|
||||||
|
->where('tenant_codigo', $tenantCodigo)
|
||||||
|
->where('user_id', $user->getKey())
|
||||||
|
->where('status', 'active')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($userCart !== null) {
|
||||||
|
$userCart->update([
|
||||||
|
'status' => 'converted',
|
||||||
|
]);
|
||||||
|
$userCart->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$guestCart->update([
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'guest_token' => null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ use App\Domains\Cart\Controllers\CartController;
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('tenants/{tenant:codigo}')
|
Route::prefix('tenants/{tenant:codigo}')
|
||||||
->middleware(\App\Domains\Cart\Middleware\MergeGuestCartMiddleware::class)
|
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('cart', [CartController::class, 'show']);
|
Route::get('cart', [CartController::class, 'show']);
|
||||||
Route::post('cart/items', [CartController::class, 'addItem']);
|
Route::post('cart/items', [CartController::class, 'addItem']);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
|
use App\Domains\Catalog\Requests\AdminApp\UpsertOnTicketFeaturedGroupRequest;
|
||||||
|
use App\Domains\Catalog\Resources\AdminApp\OnTicketFeaturedGroupResource;
|
||||||
|
use App\Domains\Catalog\Services\OnTicketFeaturedGroupService;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
class OnTicketFeaturedGroupController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly OnTicketFeaturedGroupService $featuredGroupService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
return OnTicketFeaturedGroupResource::collection(
|
||||||
|
$this->featuredGroupService->forTenant($this->onTicketTenant($request))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(UpsertOnTicketFeaturedGroupRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$featuredGroup = $this->featuredGroupService->create(
|
||||||
|
$this->onTicketTenant($request),
|
||||||
|
$request->validated(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return OnTicketFeaturedGroupResource::make($featuredGroup)
|
||||||
|
->response()
|
||||||
|
->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(
|
||||||
|
UpsertOnTicketFeaturedGroupRequest $request,
|
||||||
|
FeaturedGroup $featuredGroup,
|
||||||
|
): OnTicketFeaturedGroupResource {
|
||||||
|
$tenant = $this->onTicketTenant($request);
|
||||||
|
|
||||||
|
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
|
||||||
|
|
||||||
|
return OnTicketFeaturedGroupResource::make(
|
||||||
|
$this->featuredGroupService->update(
|
||||||
|
$tenant,
|
||||||
|
$featuredGroup,
|
||||||
|
$request->validated(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function onTicketTenant(Request $request): Tenant
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
abort_unless($tenant->website_type_code === 'onticket', 404);
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,25 +3,27 @@
|
|||||||
namespace App\Domains\Catalog\Controllers;
|
namespace App\Domains\Catalog\Controllers;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
|
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
|
||||||
|
use App\Domains\Catalog\Requests\CategoryPageRequest;
|
||||||
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
|
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
|
||||||
|
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
|
||||||
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
|
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
|
||||||
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
|
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
|
||||||
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
|
|
||||||
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
|
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
|
||||||
use App\Domains\Catalog\Resources\CatalogItemResource;
|
use App\Domains\Catalog\Resources\CatalogItemResource;
|
||||||
|
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
|
||||||
use App\Domains\Catalog\Services\CatalogService;
|
use App\Domains\Catalog\Services\CatalogService;
|
||||||
|
use App\Domains\Catalog\Services\FeaturedGroupService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
class CatalogController extends Controller
|
class CatalogController extends Controller
|
||||||
{
|
{
|
||||||
private const ITEMS_PER_PAGE = 12;
|
public function index(Tenant $tenant, FeaturedGroupService $featuredGroupService): JsonResponse
|
||||||
|
|
||||||
public function index(Tenant $tenant): JsonResponse
|
|
||||||
{
|
{
|
||||||
$featuredGroups = FeaturedGroup::query()
|
$featuredGroups = FeaturedGroup::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
@@ -31,21 +33,63 @@ class CatalogController extends Controller
|
|||||||
return response()->json($featuredGroups->map(
|
return response()->json($featuredGroups->map(
|
||||||
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
|
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
|
||||||
$featuredGroup,
|
$featuredGroup,
|
||||||
$this->featuredItemsResponse($featuredGroup, 1),
|
$featuredGroupService->itemsResponse($featuredGroup, 1),
|
||||||
))->resolve()
|
))->resolve()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function search(
|
||||||
|
SearchCatalogItemsRequest $request,
|
||||||
|
Tenant $tenant,
|
||||||
|
CatalogService $catalogService,
|
||||||
|
): AnonymousResourceCollection {
|
||||||
|
return CatalogSearchItemResource::collection(
|
||||||
|
$catalogService->search(
|
||||||
|
$tenant,
|
||||||
|
$request->validated('q'),
|
||||||
|
$tenant->search_items_per_page,
|
||||||
|
(int) $request->validated('page', 1),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
public function featuredGroupItems(
|
||||||
FeaturedGroupPageRequest $request,
|
FeaturedGroupPageRequest $request,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
FeaturedGroup $featuredGroup,
|
FeaturedGroup $featuredGroup,
|
||||||
|
FeaturedGroupService $featuredGroupService,
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
|
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
|
||||||
|
|
||||||
$page = (int) $request->validated('page', 1);
|
$page = (int) $request->validated('page', 1);
|
||||||
|
|
||||||
return response()->json($this->featuredItemsResponse($featuredGroup, $page));
|
return response()->json($featuredGroupService->itemsResponse($featuredGroup, $page));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(
|
public function show(
|
||||||
@@ -80,44 +124,4 @@ class CatalogController extends Controller
|
|||||||
->response()
|
->response()
|
||||||
->setStatusCode(201);
|
->setStatusCode(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
private function featuredItemsResponse(FeaturedGroup $featuredGroup, int $page): array
|
|
||||||
{
|
|
||||||
$paginator = $this->paginateFeaturedItems($featuredGroup, $page);
|
|
||||||
|
|
||||||
$paginator->getCollection()->each(
|
|
||||||
fn ($featuredItem) => $featuredItem->setRelation('featuredGroup', $featuredGroup)
|
|
||||||
);
|
|
||||||
|
|
||||||
return CatalogFeaturedItemResource::collection($paginator)
|
|
||||||
->response()
|
|
||||||
->getData(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function paginateFeaturedItems(
|
|
||||||
FeaturedGroup $featuredGroup,
|
|
||||||
int $page,
|
|
||||||
): LengthAwarePaginator {
|
|
||||||
$paginator = $featuredGroup->featuredItems()
|
|
||||||
->with([
|
|
||||||
'catalogItem.inventory',
|
|
||||||
'catalogItem.attachments',
|
|
||||||
'catalogItem.variants.inventory',
|
|
||||||
'catalogItem.variants.attachments',
|
|
||||||
'catalogItem.variants.definitions.itemAttribute.attribute',
|
|
||||||
'catalogItem.bundleComponents.catalogItem',
|
|
||||||
'catalogItem.bundleComponents.variant.catalogItem',
|
|
||||||
])
|
|
||||||
->paginate(
|
|
||||||
perPage: self::ITEMS_PER_PAGE,
|
|
||||||
pageName: 'page',
|
|
||||||
page: $page,
|
|
||||||
);
|
|
||||||
|
|
||||||
return $paginator->withPath(route('catalog.featured-groups.items.index', [
|
|
||||||
'tenant' => $featuredGroup->tenant_code,
|
|
||||||
'featuredGroup' => $featuredGroup->id,
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
15
app/Domains/Catalog/Enums/EventProductType.php
Normal file
15
app/Domains/Catalog/Enums/EventProductType.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Enums;
|
||||||
|
|
||||||
|
enum EventProductType: string
|
||||||
|
{
|
||||||
|
case Entry = 'entrada';
|
||||||
|
case Product = 'producto';
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_column(self::cases(), 'value');
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Domains/Catalog/Enums/FeaturedGroupSource.php
Normal file
18
app/Domains/Catalog/Enums/FeaturedGroupSource.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Enums;
|
||||||
|
|
||||||
|
enum FeaturedGroupSource: string
|
||||||
|
{
|
||||||
|
case Manual = 'manual';
|
||||||
|
case Category = 'category';
|
||||||
|
case All = 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_column(self::cases(), 'value');
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Domains/Catalog/Enums/GroupLayout.php
Normal file
19
app/Domains/Catalog/Enums/GroupLayout.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Enums;
|
||||||
|
|
||||||
|
enum GroupLayout: string
|
||||||
|
{
|
||||||
|
case Paginated = 'paginated';
|
||||||
|
case Simple = 'simple';
|
||||||
|
case SimpleVertical = 'simple_vertical';
|
||||||
|
case Carousel = 'carousel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_column(self::cases(), 'value');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,13 @@ namespace App\Domains\Catalog\Models;
|
|||||||
|
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||||
|
use App\Domains\Catalog\Enums\EventProductType;
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
|
use App\Domains\Event\Models\Event;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -16,6 +20,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'tenant_code',
|
'tenant_code',
|
||||||
|
'event_id',
|
||||||
|
'event_product_type',
|
||||||
'category_id',
|
'category_id',
|
||||||
'brand_id',
|
'brand_id',
|
||||||
'inventory_id',
|
'inventory_id',
|
||||||
@@ -49,6 +55,8 @@ class CatalogItem extends Model
|
|||||||
'category_id' => 'integer',
|
'category_id' => 'integer',
|
||||||
'brand_id' => 'integer',
|
'brand_id' => 'integer',
|
||||||
'inventory_id' => 'integer',
|
'inventory_id' => 'integer',
|
||||||
|
'event_id' => 'integer',
|
||||||
|
'event_product_type' => EventProductType::class,
|
||||||
'type' => CatalogItemType::class,
|
'type' => CatalogItemType::class,
|
||||||
'precio' => 'decimal:2',
|
'precio' => 'decimal:2',
|
||||||
'inventory_policy' => InventoryPolicy::class,
|
'inventory_policy' => InventoryPolicy::class,
|
||||||
@@ -64,6 +72,12 @@ class CatalogItem extends Model
|
|||||||
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Event, $this> */
|
||||||
|
public function event(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Event::class);
|
||||||
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<Category, $this> */
|
/** @return BelongsTo<Category, $this> */
|
||||||
public function category(): BelongsTo
|
public function category(): BelongsTo
|
||||||
{
|
{
|
||||||
@@ -100,6 +114,12 @@ class CatalogItem extends Model
|
|||||||
return $this->hasMany(Variant::class);
|
return $this->hasMany(Variant::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Ticket, $this> */
|
||||||
|
public function sourceTickets(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Ticket::class, 'source_catalog_item_id');
|
||||||
|
}
|
||||||
|
|
||||||
/** @return BelongsToMany<Attribute, $this> */
|
/** @return BelongsToMany<Attribute, $this> */
|
||||||
public function attributes(): BelongsToMany
|
public function attributes(): BelongsToMany
|
||||||
{
|
{
|
||||||
@@ -163,6 +183,16 @@ class CatalogItem extends Model
|
|||||||
return $this->nombre;
|
return $this->nombre;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getMinimumUseDate(): ?CarbonInterface
|
||||||
|
{
|
||||||
|
return $this->minimum_use_date;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMaximumUseDate(): ?CarbonInterface
|
||||||
|
{
|
||||||
|
return $this->maximum_use_date;
|
||||||
|
}
|
||||||
|
|
||||||
public function isBundle(): bool
|
public function isBundle(): bool
|
||||||
{
|
{
|
||||||
return $this->type === CatalogItemType::Bundle;
|
return $this->type === CatalogItemType::Bundle;
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
@@ -64,4 +66,15 @@ class Category extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(CatalogItem::class);
|
return $this->hasMany(CatalogItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsToMany<User, $this> */
|
||||||
|
public function scanners(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(
|
||||||
|
User::class,
|
||||||
|
'category_scanners',
|
||||||
|
'categoria_id',
|
||||||
|
'user_id',
|
||||||
|
)->withTimestamps();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
use App\Domains\Catalog\Enums\ProductLayout;
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
@@ -12,7 +14,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'tenant_code',
|
'tenant_code',
|
||||||
|
'source_type',
|
||||||
|
'category_id',
|
||||||
'product_layout',
|
'product_layout',
|
||||||
|
'group_layout',
|
||||||
'group_name',
|
'group_name',
|
||||||
'group_order',
|
'group_order',
|
||||||
])]
|
])]
|
||||||
@@ -24,10 +29,17 @@ class FeaturedGroup extends Model
|
|||||||
|
|
||||||
protected $table = 'featured_groups';
|
protected $table = 'featured_groups';
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'source_type' => FeaturedGroupSource::Manual->value,
|
||||||
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'source_type' => FeaturedGroupSource::class,
|
||||||
|
'category_id' => 'integer',
|
||||||
'product_layout' => ProductLayout::class,
|
'product_layout' => ProductLayout::class,
|
||||||
|
'group_layout' => GroupLayout::class,
|
||||||
'group_order' => 'integer',
|
'group_order' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -38,6 +50,12 @@ class FeaturedGroup extends Model
|
|||||||
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Category, $this> */
|
||||||
|
public function category(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Category::class);
|
||||||
|
}
|
||||||
|
|
||||||
/** @return HasMany<FeaturedItem, $this> */
|
/** @return HasMany<FeaturedItem, $this> */
|
||||||
public function featuredItems(): HasMany
|
public function featuredItems(): HasMany
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
|
use App\Domains\Event\Models\EventDate;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -12,7 +15,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'catalog_item_id',
|
'catalog_item_id',
|
||||||
|
'event_date_id',
|
||||||
'inventory_id',
|
'inventory_id',
|
||||||
|
'minimum_use_date',
|
||||||
|
'maximum_use_date',
|
||||||
])]
|
])]
|
||||||
class Variant extends Model
|
class Variant extends Model
|
||||||
{
|
{
|
||||||
@@ -26,7 +32,10 @@ class Variant extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'catalog_item_id' => 'integer',
|
'catalog_item_id' => 'integer',
|
||||||
|
'event_date_id' => 'integer',
|
||||||
'inventory_id' => 'integer',
|
'inventory_id' => 'integer',
|
||||||
|
'minimum_use_date' => 'datetime',
|
||||||
|
'maximum_use_date' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +45,18 @@ class Variant extends Model
|
|||||||
return $this->belongsTo(CatalogItem::class);
|
return $this->belongsTo(CatalogItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<EventDate, $this> */
|
||||||
|
public function eventDate(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(EventDate::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Ticket, $this> */
|
||||||
|
public function sourceTickets(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Ticket::class, 'source_variant_id');
|
||||||
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<Inventory, $this> */
|
/** @return BelongsTo<Inventory, $this> */
|
||||||
public function inventory(): BelongsTo
|
public function inventory(): BelongsTo
|
||||||
{
|
{
|
||||||
@@ -74,19 +95,20 @@ class Variant extends Model
|
|||||||
|
|
||||||
public function getName(): string
|
public function getName(): string
|
||||||
{
|
{
|
||||||
$name = $this->catalogItem->nombre;
|
return $this->catalogItem->nombre;
|
||||||
$this->loadMissing('definitions.itemAttribute.attribute');
|
}
|
||||||
$definitions = $this->definitions
|
|
||||||
->map(function (VariantDefinition $definition): ?string {
|
|
||||||
$attributeName = $definition->itemAttribute?->attribute?->nombre;
|
|
||||||
|
|
||||||
return $attributeName
|
public function getMinimumUseDate(): ?CarbonInterface
|
||||||
? "{$attributeName}: {$definition->value}"
|
{
|
||||||
: $definition->value;
|
return $this->eventDate?->startsAt()
|
||||||
})
|
?? $this->minimum_use_date
|
||||||
->filter()
|
?? $this->catalogItem->getMinimumUseDate();
|
||||||
->implode(', ');
|
}
|
||||||
|
|
||||||
return $definitions === '' ? $name : "{$name} ({$definitions})";
|
public function getMaximumUseDate(): ?CarbonInterface
|
||||||
|
{
|
||||||
|
return $this->eventDate?->endsAt()
|
||||||
|
?? $this->maximum_use_date
|
||||||
|
?? $this->catalogItem->getMaximumUseDate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Requests\AdminApp;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpsertOnTicketFeaturedGroupRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'category_name' => ['required', 'string', 'max:255'],
|
||||||
|
'is_featured' => ['required', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Domains/Catalog/Requests/SearchCatalogItemsRequest.php
Normal file
29
app/Domains/Catalog/Requests/SearchCatalogItemsRequest.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class SearchCatalogItemsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, list<string>> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'q' => ['required', 'string', 'min:2', 'max:100'],
|
||||||
|
'page' => ['sometimes', 'integer', 'min:1'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if ($this->has('q') && is_string($this->input('q'))) {
|
||||||
|
$this->merge(['q' => trim($this->string('q')->toString())]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Catalog\Requests;
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||||
|
use App\Domains\Catalog\Enums\EventProductType;
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
@@ -25,6 +26,20 @@ class StoreCatalogItemRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'tenant_code' => ['prohibited'],
|
'tenant_code' => ['prohibited'],
|
||||||
'type' => ['sometimes', Rule::enum(CatalogItemType::class)],
|
'type' => ['sometimes', Rule::enum(CatalogItemType::class)],
|
||||||
|
'event_id' => [
|
||||||
|
'sometimes',
|
||||||
|
'nullable',
|
||||||
|
'required_with:event_product_type',
|
||||||
|
Rule::exists('events', 'id')->where(
|
||||||
|
fn ($query) => $query->where('tenant_code', $tenantCode)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'event_product_type' => [
|
||||||
|
'sometimes',
|
||||||
|
'nullable',
|
||||||
|
'required_with:event_id',
|
||||||
|
Rule::enum(EventProductType::class),
|
||||||
|
],
|
||||||
'category_id' => [
|
'category_id' => [
|
||||||
'sometimes',
|
'sometimes',
|
||||||
'nullable',
|
'nullable',
|
||||||
@@ -73,9 +88,24 @@ class StoreCatalogItemRequest extends FormRequest
|
|||||||
'images.*' => ['required', new ImageOrBase64Rule],
|
'images.*' => ['required', new ImageOrBase64Rule],
|
||||||
'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
|
'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
|
||||||
'variants.*.real_stock' => ['sometimes', 'integer', 'min:0'],
|
'variants.*.real_stock' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
'variants.*.event_date_id' => [
|
||||||
|
'sometimes',
|
||||||
|
'nullable',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('event_dates', 'id')->where(
|
||||||
|
fn ($query) => $query->where('event_id', $this->input('event_id'))
|
||||||
|
),
|
||||||
|
],
|
||||||
'variants.*.inventory_id' => ['prohibited'],
|
'variants.*.inventory_id' => ['prohibited'],
|
||||||
'variants.*.reserved_stock' => ['prohibited'],
|
'variants.*.reserved_stock' => ['prohibited'],
|
||||||
'variants.*.sold_units' => ['prohibited'],
|
'variants.*.sold_units' => ['prohibited'],
|
||||||
|
'variants.*.minimum_use_date' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'variants.*.maximum_use_date' => [
|
||||||
|
'sometimes',
|
||||||
|
'nullable',
|
||||||
|
'date',
|
||||||
|
'after_or_equal:variants.*.minimum_use_date',
|
||||||
|
],
|
||||||
'variants.*.values' => ['sometimes', 'array'],
|
'variants.*.values' => ['sometimes', 'array'],
|
||||||
'variants.*.values.*' => ['nullable', 'string'],
|
'variants.*.values.*' => ['nullable', 'string'],
|
||||||
'variants.*.images' => ['sometimes', 'array'],
|
'variants.*.images' => ['sometimes', 'array'],
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Resources\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin FeaturedGroup */
|
||||||
|
class OnTicketFeaturedGroupResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'category_id' => $this->category_id,
|
||||||
|
'category_name' => $this->category->nombre,
|
||||||
|
'group_name' => $this->group_name,
|
||||||
|
'is_featured' => $this->product_layout === ProductLayout::Row,
|
||||||
|
'type' => $this->source_type->value,
|
||||||
|
'product_layout' => $this->product_layout->value,
|
||||||
|
'group_layout' => $this->group_layout->value,
|
||||||
|
'order' => $this->group_order,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
|||||||
/** @mixin FeaturedGroup */
|
/** @mixin FeaturedGroup */
|
||||||
class CatalogFeaturedGroupResource extends JsonResource
|
class CatalogFeaturedGroupResource extends JsonResource
|
||||||
{
|
{
|
||||||
/** @param array<string, mixed> $itemsPage */
|
/** @param array<array-key, mixed> $items */
|
||||||
public function __construct($resource, private readonly array $itemsPage)
|
public function __construct($resource, private readonly array $items)
|
||||||
{
|
{
|
||||||
parent::__construct($resource);
|
parent::__construct($resource);
|
||||||
}
|
}
|
||||||
@@ -22,8 +22,9 @@ class CatalogFeaturedGroupResource extends JsonResource
|
|||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'title' => $this->group_name,
|
'title' => $this->group_name,
|
||||||
'layout' => $this->product_layout->value,
|
'layout' => $this->product_layout->value,
|
||||||
|
'group_layout' => $this->group_layout->value,
|
||||||
'group_order' => $this->group_order,
|
'group_order' => $this->group_order,
|
||||||
'items' => $this->itemsPage,
|
'items' => $this->items,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,22 @@ namespace App\Domains\Catalog\Resources;
|
|||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Catalog\Enums\ProductLayout;
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\FeaturedItem;
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
/** @mixin FeaturedItem */
|
/** @mixin CatalogItem */
|
||||||
class CatalogFeaturedItemResource extends JsonResource
|
class CatalogFeaturedItemResource extends JsonResource
|
||||||
{
|
{
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
$catalogItem = $this->catalogItem;
|
$catalogItem = $this->resource;
|
||||||
|
/** @var FeaturedGroup $featuredGroup */
|
||||||
|
$featuredGroup = $catalogItem->getRelation('featuredGroup');
|
||||||
|
|
||||||
if ($this->featuredGroup->product_layout === ProductLayout::ColumnWithImage) {
|
if ($featuredGroup->product_layout === ProductLayout::ColumnWithImage) {
|
||||||
return $this->columnWithImageData($catalogItem);
|
return $this->columnWithImageData($catalogItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +34,8 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||||||
'variants' => $catalogItem->variants
|
'variants' => $catalogItem->variants
|
||||||
->map(fn (Variant $variant): array => [
|
->map(fn (Variant $variant): array => [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
|
'event_date_id' => $variant->event_date_id,
|
||||||
|
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
|
||||||
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||||
? null
|
? null
|
||||||
: $variant->inventory->availableStock(),
|
: $variant->inventory->availableStock(),
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'type' => $this->type->value,
|
'type' => $this->type->value,
|
||||||
|
'event_id' => $this->event_id,
|
||||||
|
'event_product_type' => $this->event_product_type?->value,
|
||||||
'category_id' => $this->category_id,
|
'category_id' => $this->category_id,
|
||||||
'brand_id' => $this->brand_id,
|
'brand_id' => $this->brand_id,
|
||||||
'slug' => $this->slug,
|
'slug' => $this->slug,
|
||||||
@@ -110,7 +112,17 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
|
'event_date_id' => $variant->event_date_id,
|
||||||
|
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
|
||||||
'stock_tecnico' => $this->variantStock($variant),
|
'stock_tecnico' => $this->variantStock($variant),
|
||||||
|
'minimum_use_date' => $variant->minimum_use_date,
|
||||||
|
'maximum_use_date' => $variant->maximum_use_date,
|
||||||
|
'effective_minimum_use_date' => $variant->eventDate?->startsAt()
|
||||||
|
?? $variant->minimum_use_date
|
||||||
|
?? $this->minimum_use_date,
|
||||||
|
'effective_maximum_use_date' => $variant->eventDate?->endsAt()
|
||||||
|
?? $variant->maximum_use_date
|
||||||
|
?? $this->maximum_use_date,
|
||||||
'values' => $variant->definitions
|
'values' => $variant->definitions
|
||||||
->mapWithKeys(fn ($definition) => [
|
->mapWithKeys(fn ($definition) => [
|
||||||
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class CatalogItemResource extends JsonResource
|
|||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'type' => $this->type->value,
|
'type' => $this->type->value,
|
||||||
|
'event_id' => $this->event_id,
|
||||||
|
'event_product_type' => $this->event_product_type?->value,
|
||||||
'category_id' => $this->category_id,
|
'category_id' => $this->category_id,
|
||||||
'brand_id' => $this->brand_id,
|
'brand_id' => $this->brand_id,
|
||||||
'slug' => $this->slug,
|
'slug' => $this->slug,
|
||||||
@@ -32,7 +34,17 @@ class CatalogItemResource extends JsonResource
|
|||||||
'variants' => $this->whenLoaded('variants', fn () => $this->variants
|
'variants' => $this->whenLoaded('variants', fn () => $this->variants
|
||||||
->map(fn ($variant) => [
|
->map(fn ($variant) => [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
|
'event_date_id' => $variant->event_date_id,
|
||||||
|
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
|
||||||
'real_stock' => $variant->inventory?->real_stock,
|
'real_stock' => $variant->inventory?->real_stock,
|
||||||
|
'minimum_use_date' => $variant->minimum_use_date,
|
||||||
|
'maximum_use_date' => $variant->maximum_use_date,
|
||||||
|
'effective_minimum_use_date' => $variant->eventDate?->startsAt()
|
||||||
|
?? $variant->minimum_use_date
|
||||||
|
?? $this->minimum_use_date,
|
||||||
|
'effective_maximum_use_date' => $variant->eventDate?->endsAt()
|
||||||
|
?? $variant->maximum_use_date
|
||||||
|
?? $this->maximum_use_date,
|
||||||
'values' => $variant->definitions
|
'values' => $variant->definitions
|
||||||
->mapWithKeys(fn ($definition) => [
|
->mapWithKeys(fn ($definition) => [
|
||||||
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
||||||
|
|||||||
47
app/Domains/Catalog/Resources/CatalogSearchItemResource.php
Normal file
47
app/Domains/Catalog/Resources/CatalogSearchItemResource.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin CatalogItem */
|
||||||
|
class CatalogSearchItemResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$attachment = $this->attachments->first()
|
||||||
|
?? $this->variants
|
||||||
|
->flatMap(fn (Variant $variant) => $variant->attachments)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'type' => $this->type->value,
|
||||||
|
'nombre' => $this->nombre,
|
||||||
|
'descripcion' => $this->descripcion,
|
||||||
|
'precio' => $this->precio,
|
||||||
|
'image' => $attachment?->getTemporaryUrl(1440),
|
||||||
|
'stock_tecnico' => $this->availableStock(),
|
||||||
|
'variants' => $this->variants
|
||||||
|
->map(fn (Variant $variant): array => [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'event_date_id' => $variant->event_date_id,
|
||||||
|
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
|
||||||
|
'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited
|
||||||
|
? null
|
||||||
|
: $variant->inventory?->availableStock(),
|
||||||
|
'values' => $variant->definitions
|
||||||
|
->mapWithKeys(fn ($definition) => [
|
||||||
|
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
||||||
|
])
|
||||||
|
->filter(fn ($value, $key): bool => $key !== null),
|
||||||
|
])
|
||||||
|
->values(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,18 @@ namespace App\Domains\Catalog\Services;
|
|||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Attachable\Services\AttachmentService;
|
use App\Domains\Attachable\Services\AttachmentService;
|
||||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||||
|
use App\Domains\Catalog\Enums\EventProductType;
|
||||||
use App\Domains\Catalog\Models\Attribute;
|
use App\Domains\Catalog\Models\Attribute;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Models\ItemAttribute;
|
use App\Domains\Catalog\Models\ItemAttribute;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Event\Models\Event;
|
||||||
|
use App\Domains\Event\Models\EventDate;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -35,14 +42,19 @@ class CatalogService
|
|||||||
$hasDirectStock = array_key_exists('real_stock', $data);
|
$hasDirectStock = array_key_exists('real_stock', $data);
|
||||||
$realStock = (int) ($data['real_stock'] ?? 0);
|
$realStock = (int) ($data['real_stock'] ?? 0);
|
||||||
|
|
||||||
$hasVariants = $attributeCodes !== [];
|
$hasEventDateVariants = $variants !== [] && collect($variants)->every(
|
||||||
|
fn (array $variant): bool => ! empty($variant['event_date_id'])
|
||||||
|
);
|
||||||
|
$hasVariants = $attributeCodes !== [] || $hasEventDateVariants;
|
||||||
|
|
||||||
|
$this->validateEventData($data);
|
||||||
|
|
||||||
if ($type === CatalogItemType::Bundle) {
|
if ($type === CatalogItemType::Bundle) {
|
||||||
$this->validateBundleData($data, $components);
|
$this->validateBundleData($data, $components);
|
||||||
} else {
|
} else {
|
||||||
if (array_key_exists('components', $data)) {
|
if (array_key_exists('components', $data)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'components' => ['Un item standard no puede tener componentes.'],
|
'components' => [__('api.catalog.standard_with_components')],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,9 +129,11 @@ class CatalogService
|
|||||||
'inventory',
|
'inventory',
|
||||||
'category',
|
'category',
|
||||||
'brand',
|
'brand',
|
||||||
|
'event',
|
||||||
'itemAttributes.attribute',
|
'itemAttributes.attribute',
|
||||||
'variants.inventory',
|
'variants.inventory',
|
||||||
'variants.attachments',
|
'variants.attachments',
|
||||||
|
'variants.eventDate',
|
||||||
'variants.definitions.itemAttribute.attribute',
|
'variants.definitions.itemAttribute.attribute',
|
||||||
'bundleComponents.catalogItem',
|
'bundleComponents.catalogItem',
|
||||||
'bundleComponents.variant.catalogItem',
|
'bundleComponents.variant.catalogItem',
|
||||||
@@ -134,10 +148,12 @@ class CatalogService
|
|||||||
'inventory',
|
'inventory',
|
||||||
'category',
|
'category',
|
||||||
'brand',
|
'brand',
|
||||||
|
'event',
|
||||||
'itemAttributes.attribute.options',
|
'itemAttributes.attribute.options',
|
||||||
'variants' => fn ($query) => $query->orderBy('id'),
|
'variants' => fn ($query) => $query->orderBy('id'),
|
||||||
'variants.inventory',
|
'variants.inventory',
|
||||||
'variants.attachments',
|
'variants.attachments',
|
||||||
|
'variants.eventDate',
|
||||||
'variants.definitions' => fn ($query) => $query->orderBy('id'),
|
'variants.definitions' => fn ($query) => $query->orderBy('id'),
|
||||||
'variants.definitions.itemAttribute.attribute',
|
'variants.definitions.itemAttribute.attribute',
|
||||||
'bundleComponents.catalogItem.inventory',
|
'bundleComponents.catalogItem.inventory',
|
||||||
@@ -158,6 +174,80 @@ class CatalogService
|
|||||||
return $catalogItem;
|
return $catalogItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return LengthAwarePaginator<CatalogItem> */
|
||||||
|
public function search(
|
||||||
|
Tenant $tenant,
|
||||||
|
string $term,
|
||||||
|
int $perPage,
|
||||||
|
int $page,
|
||||||
|
): LengthAwarePaginator {
|
||||||
|
$normalizedTerm = mb_strtolower($term);
|
||||||
|
$containsPattern = "%{$normalizedTerm}%";
|
||||||
|
$startsWithPattern = "{$normalizedTerm}%";
|
||||||
|
|
||||||
|
$paginator = CatalogItem::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where(function (Builder $query) use ($containsPattern): void {
|
||||||
|
$query
|
||||||
|
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||||
|
->orWhereRaw('LOWER(descripcion) LIKE ?', [$containsPattern])
|
||||||
|
->orWhereHas(
|
||||||
|
'brand',
|
||||||
|
fn (Builder $brandQuery) => $brandQuery
|
||||||
|
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||||
|
)
|
||||||
|
->orWhereHas(
|
||||||
|
'category',
|
||||||
|
fn (Builder $categoryQuery) => $categoryQuery
|
||||||
|
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->with([
|
||||||
|
'attachments',
|
||||||
|
'inventory',
|
||||||
|
'variants.inventory',
|
||||||
|
'variants.attachments',
|
||||||
|
'variants.eventDate',
|
||||||
|
'variants.definitions.itemAttribute.attribute',
|
||||||
|
'bundleComponents.catalogItem',
|
||||||
|
'bundleComponents.variant.catalogItem',
|
||||||
|
])
|
||||||
|
->orderByRaw(
|
||||||
|
'CASE WHEN LOWER(nombre) = ? THEN 0 WHEN LOWER(nombre) LIKE ? THEN 1 ELSE 2 END',
|
||||||
|
[$normalizedTerm, $startsWithPattern],
|
||||||
|
)
|
||||||
|
->orderBy('nombre')
|
||||||
|
->paginate(perPage: $perPage, pageName: 'page', page: $page);
|
||||||
|
|
||||||
|
return $paginator->withPath(route('catalog-items.index', [
|
||||||
|
'tenant' => $tenant->codigo,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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.eventDate',
|
||||||
|
'variants.definitions.itemAttribute.attribute',
|
||||||
|
'bundleComponents.catalogItem',
|
||||||
|
'bundleComponents.variant.catalogItem',
|
||||||
|
])
|
||||||
|
->orderBy('nombre')
|
||||||
|
->paginate(perPage: $perPage, pageName: 'page', page: $page);
|
||||||
|
}
|
||||||
|
|
||||||
public function delete(CatalogItem $catalogItem): void
|
public function delete(CatalogItem $catalogItem): void
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($catalogItem): void {
|
DB::transaction(function () use ($catalogItem): void {
|
||||||
@@ -213,7 +303,7 @@ class CatalogService
|
|||||||
|
|
||||||
if (isset($seen[$key])) {
|
if (isset($seen[$key])) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
"components.{$index}" => ['El componente esta duplicado.'],
|
"components.{$index}" => [__('api.catalog.duplicate_component')],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$seen[$key] = true;
|
$seen[$key] = true;
|
||||||
@@ -226,7 +316,7 @@ class CatalogService
|
|||||||
if ($componentItem === null) {
|
if ($componentItem === null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
"components.{$index}.catalog_item_id" => [
|
"components.{$index}.catalog_item_id" => [
|
||||||
'El item no pertenece al tenant del bundle.',
|
__('api.catalog.component_wrong_tenant'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -234,7 +324,7 @@ class CatalogService
|
|||||||
if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) {
|
if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
"components.{$index}.catalog_item_id" => [
|
"components.{$index}.catalog_item_id" => [
|
||||||
'El componente debe ser un item standard distinto del bundle.',
|
__('api.catalog.invalid_component'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -243,7 +333,7 @@ class CatalogService
|
|||||||
if ($hasVariants && $variantId === null) {
|
if ($hasVariants && $variantId === null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
"components.{$index}.variant_id" => [
|
"components.{$index}.variant_id" => [
|
||||||
'Debe seleccionar una variante para este componente.',
|
__('api.catalog.component_variant_required'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -251,7 +341,7 @@ class CatalogService
|
|||||||
if (! $hasVariants && $variantId !== null) {
|
if (! $hasVariants && $variantId !== null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
"components.{$index}.variant_id" => [
|
"components.{$index}.variant_id" => [
|
||||||
'El componente con inventario directo no admite una variante.',
|
__('api.catalog.component_variant_forbidden'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -259,7 +349,7 @@ class CatalogService
|
|||||||
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
|
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
"components.{$index}.variant_id" => [
|
"components.{$index}.variant_id" => [
|
||||||
'La variante no pertenece al componente indicado.',
|
__('api.catalog.component_variant_invalid'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -281,7 +371,7 @@ class CatalogService
|
|||||||
{
|
{
|
||||||
if ($components === []) {
|
if ($components === []) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'components' => ['Un bundle debe tener al menos un componente.'],
|
'components' => [__('api.catalog.bundle_component_required')],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +386,7 @@ class CatalogService
|
|||||||
] as $field) {
|
] as $field) {
|
||||||
if (array_key_exists($field, $data)) {
|
if (array_key_exists($field, $data)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
$field => ["{$field} no se admite para un bundle."],
|
$field => [__('api.catalog.bundle_field_forbidden', ['field' => $field])],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,7 +414,7 @@ class CatalogService
|
|||||||
|
|
||||||
if ($attachment === null) {
|
if ($attachment === null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
$validationKey => ['El attachment indicado no existe.'],
|
$validationKey => [__('api.catalog.attachment_not_found')],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +446,7 @@ class CatalogService
|
|||||||
if ($attribute === null) {
|
if ($attribute === null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'attribute_codes' => [
|
'attribute_codes' => [
|
||||||
"El atributo {$attributeCode} no existe para el tenant del ítem.",
|
__('api.catalog.attribute_not_found', ['attribute' => $attributeCode]),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -390,15 +480,40 @@ class CatalogService
|
|||||||
) {
|
) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
"variants.{$index}.inventory" => [
|
"variants.{$index}.inventory" => [
|
||||||
'inventory_id, reserved_stock y sold_units son administrados internamente.',
|
__('api.catalog.managed_inventory_fields'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
|
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
|
||||||
|
$eventDateId = $data['event_date_id'] ?? null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
$eventDateId !== null
|
||||||
|
&& (
|
||||||
|
$catalogItem->event_id === null
|
||||||
|
|| ! EventDate::query()
|
||||||
|
->whereKey($eventDateId)
|
||||||
|
->where('event_id', $catalogItem->event_id)
|
||||||
|
->exists()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"variants.{$index}.event_date_id" => [
|
||||||
|
'The event date must belong to the catalog item event.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$variant = $catalogItem->variants()->create([
|
$variant = $catalogItem->variants()->create([
|
||||||
'inventory_id' => $inventory->id,
|
'inventory_id' => $inventory->id,
|
||||||
|
'event_date_id' => $eventDateId,
|
||||||
|
'minimum_use_date' => $data['minimum_use_date'] ?? null,
|
||||||
|
'maximum_use_date' => $data['maximum_use_date'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
$variant->setRelation('catalogItem', $catalogItem);
|
||||||
|
|
||||||
|
$this->validateVariantUseDates($variant, $index);
|
||||||
|
|
||||||
foreach ($data['values'] ?? [] as $attributeCode => $value) {
|
foreach ($data['values'] ?? [] as $attributeCode => $value) {
|
||||||
$itemAttribute = $itemAttributes[$attributeCode] ?? null;
|
$itemAttribute = $itemAttributes[$attributeCode] ?? null;
|
||||||
@@ -406,7 +521,7 @@ class CatalogService
|
|||||||
if ($itemAttribute === null) {
|
if ($itemAttribute === null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
"variants.{$index}.values.{$attributeCode}" => [
|
"variants.{$index}.values.{$attributeCode}" => [
|
||||||
'El atributo no pertenece al ítem de catálogo.',
|
__('api.catalog.attribute_not_on_item'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -420,6 +535,56 @@ class CatalogService
|
|||||||
return $variant;
|
return $variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
private function validateEventData(array $data): void
|
||||||
|
{
|
||||||
|
$eventId = $data['event_id'] ?? null;
|
||||||
|
$eventProductType = $data['event_product_type'] ?? null;
|
||||||
|
|
||||||
|
if (($eventId === null) !== ($eventProductType === null)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'event_id' => ['Event and event product type must be provided together.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($eventId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Event::query()
|
||||||
|
->whereKey($eventId)
|
||||||
|
->where('tenant_code', $data['tenant_code'] ?? null)
|
||||||
|
->exists()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'event_id' => ['The event must belong to the catalog item tenant.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($eventProductType, EventProductType::values(), true)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'event_product_type' => ['The event product type is invalid.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateVariantUseDates(Variant $variant, int $index): void
|
||||||
|
{
|
||||||
|
$minimumUseDate = $variant->getMinimumUseDate();
|
||||||
|
$maximumUseDate = $variant->getMaximumUseDate();
|
||||||
|
|
||||||
|
if (
|
||||||
|
$minimumUseDate !== null
|
||||||
|
&& $maximumUseDate !== null
|
||||||
|
&& $maximumUseDate->lessThan($minimumUseDate)
|
||||||
|
) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"variants.{$index}.maximum_use_date" => [
|
||||||
|
__('api.catalog.invalid_effective_date_range'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
* @param array<int, array<string, mixed>> $variants
|
* @param array<int, array<string, mixed>> $variants
|
||||||
@@ -433,7 +598,7 @@ class CatalogService
|
|||||||
if ($hasVariants && $variants === []) {
|
if ($hasVariants && $variants === []) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'variants' => [
|
'variants' => [
|
||||||
'Un ítem con attribute_codes debe tener variantes.',
|
__('api.catalog.variants_required'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -441,7 +606,7 @@ class CatalogService
|
|||||||
if (! $hasVariants && $variants !== []) {
|
if (! $hasVariants && $variants !== []) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'variants' => [
|
'variants' => [
|
||||||
'Un ítem sin attribute_codes no puede tener variantes.',
|
__('api.catalog.variants_forbidden'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -449,7 +614,7 @@ class CatalogService
|
|||||||
if ($hasVariants && $hasDirectStock) {
|
if ($hasVariants && $hasDirectStock) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'real_stock' => [
|
'real_stock' => [
|
||||||
'Un ítem con variantes no puede tener inventario directo.',
|
__('api.catalog.direct_inventory_forbidden'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -461,7 +626,7 @@ class CatalogService
|
|||||||
) {
|
) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'inventory' => [
|
'inventory' => [
|
||||||
'inventory_id, reserved_stock y sold_units son administrados internamente.',
|
__('api.catalog.managed_inventory_fields'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
87
app/Domains/Catalog/Services/FeaturedGroupService.php
Normal file
87
app/Domains/Catalog/Services/FeaturedGroupService.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
|
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
class FeaturedGroupService
|
||||||
|
{
|
||||||
|
private const ITEMS_PER_PAGE = 12;
|
||||||
|
|
||||||
|
/** @return array<array-key, mixed> */
|
||||||
|
public function itemsResponse(FeaturedGroup $featuredGroup, int $page): array
|
||||||
|
{
|
||||||
|
if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
|
||||||
|
$items = $this->itemsQuery($featuredGroup)->get();
|
||||||
|
$this->attachGroup($items, $featuredGroup);
|
||||||
|
|
||||||
|
return CatalogFeaturedItemResource::collection($items)->resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
$paginator = $this->paginateItems($featuredGroup, $page);
|
||||||
|
$this->attachGroup($paginator->getCollection(), $featuredGroup);
|
||||||
|
|
||||||
|
return CatalogFeaturedItemResource::collection($paginator)
|
||||||
|
->response()
|
||||||
|
->getData(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Builder<CatalogItem> */
|
||||||
|
private function itemsQuery(FeaturedGroup $featuredGroup): Builder
|
||||||
|
{
|
||||||
|
$query = CatalogItem::query()
|
||||||
|
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
|
||||||
|
->with([
|
||||||
|
'inventory',
|
||||||
|
'attachments',
|
||||||
|
'variants.inventory',
|
||||||
|
'variants.attachments',
|
||||||
|
'variants.eventDate',
|
||||||
|
'variants.definitions.itemAttribute.attribute',
|
||||||
|
'bundleComponents.catalogItem',
|
||||||
|
'bundleComponents.variant.catalogItem',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return match ($featuredGroup->source_type) {
|
||||||
|
FeaturedGroupSource::Manual => $query
|
||||||
|
->select('catalog_items.*')
|
||||||
|
->join('featured_items', 'featured_items.catalog_item_id', '=', 'catalog_items.id')
|
||||||
|
->where('featured_items.featured_group_id', $featuredGroup->id)
|
||||||
|
->orderBy('featured_items.order')
|
||||||
|
->orderBy('featured_items.id'),
|
||||||
|
FeaturedGroupSource::Category => $query
|
||||||
|
->where('catalog_items.category_id', $featuredGroup->category_id)
|
||||||
|
->orderBy('catalog_items.id'),
|
||||||
|
FeaturedGroupSource::All => $query->orderBy('catalog_items.id'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function paginateItems(
|
||||||
|
FeaturedGroup $featuredGroup,
|
||||||
|
int $page,
|
||||||
|
): LengthAwarePaginator {
|
||||||
|
$paginator = $this->itemsQuery($featuredGroup)->paginate(
|
||||||
|
perPage: self::ITEMS_PER_PAGE,
|
||||||
|
pageName: 'page',
|
||||||
|
page: $page,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $paginator->withPath(route('catalog.featured-groups.items.index', [
|
||||||
|
'tenant' => $featuredGroup->tenant_code,
|
||||||
|
'featuredGroup' => $featuredGroup->id,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attachGroup(iterable $items, FeaturedGroup $featuredGroup): void
|
||||||
|
{
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$item->setRelation('featuredGroup', $featuredGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class OnTicketFeaturedGroupService
|
||||||
|
{
|
||||||
|
/** @return Collection<int, FeaturedGroup> */
|
||||||
|
public function forTenant(Tenant $tenant): Collection
|
||||||
|
{
|
||||||
|
return FeaturedGroup::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('source_type', FeaturedGroupSource::Category)
|
||||||
|
->whereHas('category', fn ($query) => $query->where('tenant_code', $tenant->codigo))
|
||||||
|
->with('category')
|
||||||
|
->orderBy('group_order')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{category_name: string, is_featured: bool} $data */
|
||||||
|
public function create(Tenant $tenant, array $data): FeaturedGroup
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $data): FeaturedGroup {
|
||||||
|
$category = $tenant->categories()->create([
|
||||||
|
'nombre' => $data['category_name'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$featuredGroup = FeaturedGroup::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'source_type' => FeaturedGroupSource::Category,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'product_layout' => $this->productLayout($data['is_featured']),
|
||||||
|
'group_layout' => GroupLayout::Paginated,
|
||||||
|
'group_name' => $data['category_name'],
|
||||||
|
'group_order' => $this->nextOrder($tenant),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $featuredGroup->setRelation('category', $category);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{category_name: string, is_featured: bool} $data */
|
||||||
|
public function update(
|
||||||
|
Tenant $tenant,
|
||||||
|
FeaturedGroup $featuredGroup,
|
||||||
|
array $data,
|
||||||
|
): FeaturedGroup {
|
||||||
|
return DB::transaction(function () use ($tenant, $featuredGroup, $data): FeaturedGroup {
|
||||||
|
$featuredGroup = FeaturedGroup::query()
|
||||||
|
->whereKey($featuredGroup->getKey())
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('source_type', FeaturedGroupSource::Category)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$category = Category::query()
|
||||||
|
->whereKey($featuredGroup->category_id)
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$category->update(['nombre' => $data['category_name']]);
|
||||||
|
$featuredGroup->update([
|
||||||
|
'group_name' => $data['category_name'],
|
||||||
|
'product_layout' => $this->productLayout($data['is_featured']),
|
||||||
|
'group_layout' => GroupLayout::Paginated,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $featuredGroup->setRelation('category', $category);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function productLayout(bool $isFeatured): ProductLayout
|
||||||
|
{
|
||||||
|
return $isFeatured ? ProductLayout::Row : ProductLayout::ColumnWithCart;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nextOrder(Tenant $tenant): int
|
||||||
|
{
|
||||||
|
$maximumOrder = FeaturedGroup::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->max('group_order');
|
||||||
|
|
||||||
|
return $maximumOrder === null ? 0 : ((int) $maximumOrder) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/Domains/Catalog/routes/adminapp.php
Normal file
15
app/Domains/Catalog/routes/adminapp.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp/tenant')
|
||||||
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->group(function (): void {
|
||||||
|
Route::get('featured-groups', [OnTicketFeaturedGroupController::class, 'index'])
|
||||||
|
->name('adminapp.featured-groups.index');
|
||||||
|
Route::post('featured-groups', [OnTicketFeaturedGroupController::class, 'store'])
|
||||||
|
->name('adminapp.featured-groups.store');
|
||||||
|
Route::put('featured-groups/{featuredGroup}', [OnTicketFeaturedGroupController::class, 'update'])
|
||||||
|
->name('adminapp.featured-groups.update');
|
||||||
|
});
|
||||||
@@ -7,6 +7,12 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
|||||||
Route::get('catalog', [CatalogController::class, 'index']);
|
Route::get('catalog', [CatalogController::class, 'index']);
|
||||||
Route::get('catalog/featured-groups/{featuredGroup}/items', [CatalogController::class, 'featuredGroupItems'])
|
Route::get('catalog/featured-groups/{featuredGroup}/items', [CatalogController::class, 'featuredGroupItems'])
|
||||||
->name('catalog.featured-groups.items.index');
|
->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::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
|
||||||
Route::post('catalog-items', [CatalogController::class, 'store']);
|
Route::post('catalog-items', [CatalogController::class, 'store']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
require __DIR__.'/adminapp.php';
|
||||||
|
|||||||
31
app/Domains/Event/Controllers/AdminApp/EventController.php
Normal file
31
app/Domains/Event/Controllers/AdminApp/EventController.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Event\Requests\UpdateEventRequest;
|
||||||
|
use App\Domains\Event\Resources\EventResource;
|
||||||
|
use App\Domains\Event\Services\EventService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class EventController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected EventService $eventService) {}
|
||||||
|
|
||||||
|
public function show(Request $request): EventResource
|
||||||
|
{
|
||||||
|
return EventResource::make(
|
||||||
|
$this->eventService->activeForTenant($request->user()->tenant()->firstOrFail())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateEventRequest $request): EventResource
|
||||||
|
{
|
||||||
|
return EventResource::make(
|
||||||
|
$this->eventService->updateActiveForTenant(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$request->validated()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Domains/Event/Models/Event.php
Normal file
48
app/Domains/Event/Models/Event.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Models;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
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([
|
||||||
|
'tenant_code',
|
||||||
|
'name',
|
||||||
|
'address',
|
||||||
|
])]
|
||||||
|
class Event extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
/** @return BelongsTo<Tenant, $this> */
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<EventDate, $this> */
|
||||||
|
public function dates(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(EventDate::class)->orderBy('date')->orderBy('time_start');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<CatalogItem, $this> */
|
||||||
|
public function catalogItems(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CatalogItem::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Purchase, $this> */
|
||||||
|
public function purchases(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Purchase::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Domains/Event/Models/EventDate.php
Normal file
55
app/Domains/Event/Models/EventDate.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Models;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
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;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'event_id',
|
||||||
|
'date',
|
||||||
|
'time_start',
|
||||||
|
'time_end',
|
||||||
|
])]
|
||||||
|
class EventDate extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'event_id' => 'integer',
|
||||||
|
'date' => 'date:Y-m-d',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Event, $this> */
|
||||||
|
public function event(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Event::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Variant, $this> */
|
||||||
|
public function variants(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Variant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startsAt(): CarbonInterface
|
||||||
|
{
|
||||||
|
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_start);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function endsAt(): CarbonInterface
|
||||||
|
{
|
||||||
|
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/Domains/Event/Requests/UpdateEventRequest.php
Normal file
60
app/Domains/Event/Requests/UpdateEventRequest.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
class UpdateEventRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => ['required', 'string', 'max:255'],
|
||||||
|
'location' => ['required', 'string', 'max:255'],
|
||||||
|
'dates' => ['required', 'array', 'min:1'],
|
||||||
|
'dates.*' => ['required', 'array:date,start_time,end_time'],
|
||||||
|
'dates.*.date' => ['required', 'date_format:Y-m-d', 'distinct'],
|
||||||
|
'dates.*.start_time' => ['required', 'date_format:H:i'],
|
||||||
|
'dates.*.end_time' => ['required', 'date_format:H:i'],
|
||||||
|
'social_media' => ['sometimes', 'array'],
|
||||||
|
'social_media.*' => ['required', 'array:code,url,orden'],
|
||||||
|
'social_media.*.code' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'distinct',
|
||||||
|
Rule::exists('social_media', 'code'),
|
||||||
|
],
|
||||||
|
'social_media.*.url' => ['required', 'url', 'max:2048'],
|
||||||
|
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
|
||||||
|
'contact' => ['sometimes', 'array:whatsapp_url,instagram_url,facebook_url'],
|
||||||
|
'contact.whatsapp_url' => ['nullable', 'url', 'max:2048'],
|
||||||
|
'contact.instagram_url' => ['nullable', 'url', 'max:2048'],
|
||||||
|
'contact.facebook_url' => ['nullable', 'url', 'max:2048'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, callable> */
|
||||||
|
public function after(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
function (Validator $validator): void {
|
||||||
|
$input = $this->all();
|
||||||
|
|
||||||
|
if (! array_key_exists('social_media', $input) && ! array_key_exists('contact', $input)) {
|
||||||
|
$validator->errors()->add(
|
||||||
|
'social_media',
|
||||||
|
'The social media field is required.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Domains/Event/Resources/EventResource.php
Normal file
39
app/Domains/Event/Resources/EventResource.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Event\Models\Event;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin Event */
|
||||||
|
class EventResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$socialMedia = $this->tenant->socialMedia->keyBy('code');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'title' => $this->name,
|
||||||
|
'location' => $this->address,
|
||||||
|
'dates' => $this->dates->map(fn ($eventDate): array => [
|
||||||
|
'id' => $eventDate->id,
|
||||||
|
'date' => $eventDate->date->format('Y-m-d'),
|
||||||
|
'start_time' => substr($eventDate->time_start, 0, 5),
|
||||||
|
'end_time' => substr($eventDate->time_end, 0, 5),
|
||||||
|
])->values(),
|
||||||
|
'social_media' => $this->tenant->socialMedia->map(fn ($item): array => [
|
||||||
|
'code' => $item->code,
|
||||||
|
'url' => $item->pivot->url,
|
||||||
|
'orden' => $item->pivot->orden,
|
||||||
|
])->values(),
|
||||||
|
'contact' => [
|
||||||
|
'whatsapp_url' => $socialMedia->get('whatsapp')?->pivot->url,
|
||||||
|
'instagram_url' => $socialMedia->get('instagram')?->pivot->url,
|
||||||
|
'facebook_url' => $socialMedia->get('facebook')?->pivot->url,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
120
app/Domains/Event/Services/EventService.php
Normal file
120
app/Domains/Event/Services/EventService.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Services;
|
||||||
|
|
||||||
|
use App\Domains\Event\Models\Event;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class EventService
|
||||||
|
{
|
||||||
|
private const CONTACT_CODES = [
|
||||||
|
'whatsapp_url' => 'whatsapp',
|
||||||
|
'instagram_url' => 'instagram',
|
||||||
|
'facebook_url' => 'facebook',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function activeForTenant(Tenant $tenant): Event
|
||||||
|
{
|
||||||
|
return $tenant->events()
|
||||||
|
->whereKey($tenant->active_event_id)
|
||||||
|
->with(['dates', 'tenant.socialMedia'])
|
||||||
|
->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
public function updateActiveForTenant(Tenant $tenant, array $data): Event
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $data): Event {
|
||||||
|
$tenant = Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
|
||||||
|
$event = $tenant->active_event_id === null
|
||||||
|
? $tenant->events()->create([
|
||||||
|
'name' => $data['title'],
|
||||||
|
'address' => $data['location'],
|
||||||
|
])
|
||||||
|
: $tenant->events()
|
||||||
|
->whereKey($tenant->active_event_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$event->update([
|
||||||
|
'name' => $data['title'],
|
||||||
|
'address' => $data['location'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($tenant->active_event_id === null) {
|
||||||
|
$tenant->update(['active_event_id' => $event->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->syncDates($event, $data['dates']);
|
||||||
|
if (array_key_exists('social_media', $data)) {
|
||||||
|
$this->syncSocialMedia($tenant, $data['social_media']);
|
||||||
|
} else {
|
||||||
|
$this->syncLegacyContact($tenant, $data['contact']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $event->load(['dates', 'tenant.socialMedia']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, array{date: string, start_time: string, end_time: string}> $dates */
|
||||||
|
private function syncDates(Event $event, array $dates): void
|
||||||
|
{
|
||||||
|
$existingDates = $event->dates()->get()->values();
|
||||||
|
|
||||||
|
foreach (array_values($dates) as $index => $date) {
|
||||||
|
$attributes = [
|
||||||
|
'date' => $date['date'],
|
||||||
|
'time_start' => $date['start_time'],
|
||||||
|
'time_end' => $date['end_time'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$existingDate = $existingDates->get($index);
|
||||||
|
|
||||||
|
if ($existingDate) {
|
||||||
|
$existingDate->update($attributes);
|
||||||
|
} else {
|
||||||
|
$event->dates()->create($attributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingDates->slice(count($dates))->each->delete();
|
||||||
|
$event->unsetRelation('dates');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, string|null> $contact */
|
||||||
|
private function syncLegacyContact(Tenant $tenant, array $contact): void
|
||||||
|
{
|
||||||
|
foreach (self::CONTACT_CODES as $field => $code) {
|
||||||
|
$url = $contact[$field] ?? null;
|
||||||
|
|
||||||
|
if ($url === null || $url === '') {
|
||||||
|
$tenant->socialMedia()->detach($code);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->socialMedia()->syncWithoutDetaching([
|
||||||
|
$code => ['url' => $url],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->unsetRelation('socialMedia');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, array{code: string, url: string, orden?: int}> $socialMedia */
|
||||||
|
private function syncSocialMedia(Tenant $tenant, array $socialMedia): void
|
||||||
|
{
|
||||||
|
$associations = [];
|
||||||
|
|
||||||
|
foreach (array_values($socialMedia) as $index => $item) {
|
||||||
|
$associations[$item['code']] = [
|
||||||
|
'url' => $item['url'],
|
||||||
|
'orden' => $item['orden'] ?? $index,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->socialMedia()->sync($associations);
|
||||||
|
$tenant->unsetRelation('socialMedia');
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/Domains/Event/routes/adminapp.php
Normal file
11
app/Domains/Event/routes/adminapp.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Event\Controllers\AdminApp\EventController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp/tenant')
|
||||||
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->group(function (): void {
|
||||||
|
Route::get('event', [EventController::class, 'show']);
|
||||||
|
Route::put('event', [EventController::class, 'update']);
|
||||||
|
});
|
||||||
3
app/Domains/Event/routes/api.php
Normal file
3
app/Domains/Event/routes/api.php
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require __DIR__.'/adminapp.php';
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\EventFormResource;
|
||||||
|
use App\Domains\Forms\Services\EventFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class EventFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected EventFormService $eventFormService) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): EventFormResource
|
||||||
|
{
|
||||||
|
return EventFormResource::make(
|
||||||
|
$this->eventFormService->get(
|
||||||
|
$request->user('sanctum')->tenant()->firstOrFail()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\SaleFormResource;
|
||||||
|
use App\Domains\Forms\Services\SaleFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
class SaleFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected SaleFormService $saleFormService) {}
|
||||||
|
|
||||||
|
public function __invoke(): SaleFormResource
|
||||||
|
{
|
||||||
|
return SaleFormResource::make($this->saleFormService->get());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\StaffFormResource;
|
||||||
|
use App\Domains\Forms\Services\StaffFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class StaffFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected StaffFormService $staffFormService) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): StaffFormResource
|
||||||
|
{
|
||||||
|
return StaffFormResource::make(
|
||||||
|
$this->staffFormService->get(
|
||||||
|
$request->user('sanctum')->tenant()->firstOrFail()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Domains/Forms/Resources/EventFormResource.php
Normal file
19
app/Domains/Forms/Resources/EventFormResource.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class EventFormResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'social_media' => SocialMediaOptionResource::collection(
|
||||||
|
$this->resource['social_media']
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Domains/Forms/Resources/SaleFormResource.php
Normal file
17
app/Domains/Forms/Resources/SaleFormResource.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class SaleFormResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'statuses' => $this->resource['statuses'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Domains/Forms/Resources/SocialMediaOptionResource.php
Normal file
22
app/Domains/Forms/Resources/SocialMediaOptionResource.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\SocialMedia;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin SocialMedia */
|
||||||
|
class SocialMediaOptionResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'code' => $this->code,
|
||||||
|
'name' => $this->name,
|
||||||
|
'icon' => $this->icon,
|
||||||
|
'url' => $this->url,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Domains/Forms/Resources/StaffFormResource.php
Normal file
20
app/Domains/Forms/Resources/StaffFormResource.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class StaffFormResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'categories' => $this->resource['categories']->map(fn ($category) => [
|
||||||
|
'id' => $category->id,
|
||||||
|
'nombre' => $category->nombre,
|
||||||
|
])->values(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Domains/Forms/Services/EventFormService.php
Normal file
27
app/Domains/Forms/Services/EventFormService.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\SocialMedia;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class EventFormService
|
||||||
|
{
|
||||||
|
/** @return array{social_media: Collection<int, SocialMedia>} */
|
||||||
|
public function get(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$urls = $tenant->socialMedia()
|
||||||
|
->pluck('tenant_social_media.url', 'social_media.code');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'social_media' => SocialMedia::query()
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->each(fn (SocialMedia $item) => $item->setAttribute(
|
||||||
|
'url',
|
||||||
|
$urls->get($item->code)
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Domains/Forms/Services/SaleFormService.php
Normal file
31
app/Domains/Forms/Services/SaleFormService.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
|
||||||
|
class SaleFormService
|
||||||
|
{
|
||||||
|
/** @return array{statuses: list<array{code: string, name: string}>} */
|
||||||
|
public function get(): array
|
||||||
|
{
|
||||||
|
$names = [
|
||||||
|
Purchase::STATUS_CREATED => 'Creada',
|
||||||
|
Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago',
|
||||||
|
Purchase::STATUS_PAID => 'Confirmada',
|
||||||
|
Purchase::STATUS_CANCELLED => 'Cancelada',
|
||||||
|
Purchase::STATUS_REJECTED => 'Rechazada',
|
||||||
|
Purchase::STATUS_EXPIRED => 'Vencida',
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'statuses' => array_map(
|
||||||
|
fn (string $status): array => [
|
||||||
|
'code' => $status,
|
||||||
|
'name' => $names[$status],
|
||||||
|
],
|
||||||
|
Purchase::statuses(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Domains/Forms/Services/StaffFormService.php
Normal file
27
app/Domains/Forms/Services/StaffFormService.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class StaffFormService
|
||||||
|
{
|
||||||
|
/** @return array{categories: Collection<int, Category>} */
|
||||||
|
public function get(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'categories' => Category::query()
|
||||||
|
->whereNull('categoria_id')
|
||||||
|
->where(function (Builder $query) use ($tenant): void {
|
||||||
|
$query->where('tenant_code', $tenant->codigo)
|
||||||
|
->orWhereHas('catalogItems', fn (Builder $items) => $items
|
||||||
|
->where('tenant_code', $tenant->codigo));
|
||||||
|
})
|
||||||
|
->orderBy('nombre')
|
||||||
|
->get(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/Domains/Forms/routes/adminapp.php
Normal file
14
app/Domains/Forms/routes/adminapp.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Forms\Controllers\AdminApp\EventFormController;
|
||||||
|
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
|
||||||
|
use App\Domains\Forms\Controllers\AdminApp\StaffFormController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp/forms')
|
||||||
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->group(function (): void {
|
||||||
|
Route::get('event', EventFormController::class);
|
||||||
|
Route::get('sale', SaleFormController::class);
|
||||||
|
Route::get('staff', StaffFormController::class);
|
||||||
|
});
|
||||||
3
app/Domains/Forms/routes/api.php
Normal file
3
app/Domains/Forms/routes/api.php
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require __DIR__.'/adminapp.php';
|
||||||
@@ -6,28 +6,26 @@ use App\Domains\Integration\Requests\TelepagosWebhookRequest;
|
|||||||
use App\Domains\Integration\Services\TelepagosWebhookService;
|
use App\Domains\Integration\Services\TelepagosWebhookService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class TelepagosWebhookController extends Controller
|
class TelepagosWebhookController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Handle the incoming Telepagos webhook.
|
* 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
|
public function handle(TelepagosWebhookRequest $request, string $tenantCodigo, TelepagosWebhookService $service): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$cashinId = $request->validated('id');
|
$cashinId = $request->validated('id');
|
||||||
|
|
||||||
$service->handleWebhook($tenantCodigo, $cashinId);
|
$service->handleWebhook($tenantCodigo, $cashinId);
|
||||||
|
|
||||||
return response()->json(['status' => 'success']);
|
return response()->json(['status' => 'success']);
|
||||||
} catch (\Exception $e) {
|
} 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)
|
public function show(string $tenantCode, string $integrationCode)
|
||||||
{
|
{
|
||||||
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
|
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
|
||||||
|
|
||||||
if (!$integration) {
|
if (! $integration) {
|
||||||
return response()->json(['message' => 'Integration not configured for this tenant'], 404);
|
return response()->json([
|
||||||
|
'code' => 'integration.not_configured',
|
||||||
|
'message' => __('api.integration.not_configured'),
|
||||||
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($integration);
|
return response()->json($integration);
|
||||||
@@ -35,7 +38,7 @@ class TenantIntegrationController extends Controller
|
|||||||
public function store(StoreTenantIntegrationRequest $request, string $tenantCode, string $integrationCode)
|
public function store(StoreTenantIntegrationRequest $request, string $tenantCode, string $integrationCode)
|
||||||
{
|
{
|
||||||
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
|
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->tenantIntegrationService->updateOrCreateIntegration(
|
$this->tenantIntegrationService->updateOrCreateIntegration(
|
||||||
$tenantCode,
|
$tenantCode,
|
||||||
@@ -44,11 +47,13 @@ class TenantIntegrationController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'integration configured correctly',
|
'code' => 'integration.configured',
|
||||||
|
'message' => __('api.integration.configured'),
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Error validando la configuración: ' . $e->getMessage()
|
'code' => 'integration.validation_failed',
|
||||||
|
'message' => __('api.integration.validation_failed', ['error' => $e->getMessage()]),
|
||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ class StoreTenantIntegrationRequest extends FormRequest
|
|||||||
$integrationCode = $this->route('integration_code');
|
$integrationCode = $this->route('integration_code');
|
||||||
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first();
|
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first();
|
||||||
|
|
||||||
if (!$this->integrationModel) {
|
if (! $this->integrationModel) {
|
||||||
throw ValidationException::withMessages([
|
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
|
// Dynamic validation rules based on the integration data schema
|
||||||
if ($this->integrationModel && $this->integrationModel->integration_data_schema) {
|
if ($this->integrationModel && $this->integrationModel->integration_data_schema) {
|
||||||
foreach ($this->integrationModel->integration_data_schema as $field => $rule) {
|
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\Purchase\Services\CheckoutService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class TelepagosWebhookService
|
class TelepagosWebhookService
|
||||||
@@ -19,16 +20,13 @@ class TelepagosWebhookService
|
|||||||
/**
|
/**
|
||||||
* Handle the Telepagos webhook notification.
|
* Handle the Telepagos webhook notification.
|
||||||
*
|
*
|
||||||
* @param string $tenantCodigo
|
|
||||||
* @param string $cashinId
|
|
||||||
* @return void
|
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function handleWebhook(string $tenantCodigo, string $cashinId): void
|
public function handleWebhook(string $tenantCodigo, string $cashinId): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
|
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
|
||||||
|
|
||||||
$telepagosService = new TelepagosIntegrationService();
|
$telepagosService = new TelepagosIntegrationService;
|
||||||
$telepagosService->forTenant($tenant->codigo);
|
$telepagosService->forTenant($tenant->codigo);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -48,14 +46,18 @@ class TelepagosWebhookService
|
|||||||
|
|
||||||
if (! $cuit) {
|
if (! $cuit) {
|
||||||
Log::warning("Telepagos webhook: CUIT not found for Transferencia cashin {$cashinId}");
|
Log::warning("Telepagos webhook: CUIT not found for Transferencia cashin {$cashinId}");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$dni = substr($cuit, 2, -1);
|
$dni = substr($cuit, 2, -1);
|
||||||
|
|
||||||
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
|
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
|
||||||
->where('transfer_payer_dni', $dni)
|
->where('transfer_payer_dni', $dni)
|
||||||
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
->whereIn('status', [
|
||||||
|
Purchase::STATUS_CREATED,
|
||||||
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
])
|
||||||
->where('payment_method', 'transfer')
|
->where('payment_method', 'transfer')
|
||||||
->where('total', $amount)
|
->where('total', $amount)
|
||||||
->latest()
|
->latest()
|
||||||
@@ -63,11 +65,13 @@ class TelepagosWebhookService
|
|||||||
|
|
||||||
if (! $compra) {
|
if (! $compra) {
|
||||||
Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}");
|
Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
|
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
|
||||||
if (! $qrOrderId) {
|
if (! $qrOrderId) {
|
||||||
Log::warning("Telepagos webhook: qr_order_id not found for QR cashin {$cashinId}");
|
Log::warning("Telepagos webhook: qr_order_id not found for QR cashin {$cashinId}");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +79,7 @@ class TelepagosWebhookService
|
|||||||
|
|
||||||
if (! $telepagosQr) {
|
if (! $telepagosQr) {
|
||||||
Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}");
|
Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,11 +87,15 @@ class TelepagosWebhookService
|
|||||||
|
|
||||||
if (! $compra) {
|
if (! $compra) {
|
||||||
Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}");
|
Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($compra->status !== Purchase::STATUS_PENDING_PAYMENT) {
|
if (! in_array($compra->status, [
|
||||||
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
], true)) {
|
||||||
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");
|
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,10 +103,12 @@ class TelepagosWebhookService
|
|||||||
|
|
||||||
if ($amount !== $totalAmount) {
|
if ($amount !== $totalAmount) {
|
||||||
Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}");
|
Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Log::warning("Telepagos webhook: Unknown operation_id {$operationId} for cashin {$cashinId}");
|
Log::warning("Telepagos webhook: Unknown operation_id {$operationId} for cashin {$cashinId}");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +125,7 @@ class TelepagosWebhookService
|
|||||||
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
|
'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);
|
TelepagosPayment::create($paymentData);
|
||||||
$this->checkoutService->confirmPurchase($compra);
|
$this->checkoutService->confirmPurchase($compra);
|
||||||
$compra->markAsPaid();
|
$compra->markAsPaid();
|
||||||
@@ -122,7 +133,7 @@ class TelepagosWebhookService
|
|||||||
|
|
||||||
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");
|
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Log::error("Telepagos webhook error: " . $e->getMessage());
|
Log::error('Telepagos webhook error: '.$e->getMessage());
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
app/Domains/Logging/Enums/ValueChangeActorType.php
Normal file
9
app/Domains/Logging/Enums/ValueChangeActorType.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Logging\Enums;
|
||||||
|
|
||||||
|
enum ValueChangeActorType: string
|
||||||
|
{
|
||||||
|
case User = 'user';
|
||||||
|
case System = 'system';
|
||||||
|
}
|
||||||
65
app/Domains/Logging/Models/Concerns/LogsValueChanges.php
Normal file
65
app/Domains/Logging/Models/Concerns/LogsValueChanges.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Logging\Models\Concerns;
|
||||||
|
|
||||||
|
use App\Domains\Logging\Enums\ValueChangeActorType;
|
||||||
|
use App\Domains\Logging\Models\ValueChange;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
trait LogsValueChanges
|
||||||
|
{
|
||||||
|
abstract protected function valueChangeTenantCode(): string;
|
||||||
|
|
||||||
|
public static function bootLogsValueChanges(): void
|
||||||
|
{
|
||||||
|
static::updated(function (Model $model): void {
|
||||||
|
$changedAttributes = array_values(array_intersect(
|
||||||
|
$model->getLoggedAttributes(),
|
||||||
|
array_keys($model->getChanges()),
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($changedAttributes === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = Auth::id();
|
||||||
|
$actorType = $userId === null
|
||||||
|
? ValueChangeActorType::System
|
||||||
|
: ValueChangeActorType::User;
|
||||||
|
|
||||||
|
foreach ($changedAttributes as $attribute) {
|
||||||
|
$model->valueChanges()->create([
|
||||||
|
'tenant_code' => $model->valueChangeTenantCode(),
|
||||||
|
'attribute' => $attribute,
|
||||||
|
'old_value' => $model->getRawOriginal($attribute),
|
||||||
|
'new_value' => $model->getAttributes()[$attribute] ?? null,
|
||||||
|
'changed_at' => now(),
|
||||||
|
'actor_type' => $actorType,
|
||||||
|
'user_id' => $userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
|
public function getLoggedAttributes(): array
|
||||||
|
{
|
||||||
|
if (! property_exists($this, 'loggedAttributes')) {
|
||||||
|
throw new LogicException(sprintf(
|
||||||
|
'The [%s] model must define a $loggedAttributes property.',
|
||||||
|
static::class,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($this->loggedAttributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return MorphMany<ValueChange, $this> */
|
||||||
|
public function valueChanges(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(ValueChange::class, 'trackable');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Domains/Logging/Models/ValueChange.php
Normal file
55
app/Domains/Logging/Models/ValueChange.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Logging\Models;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Logging\Enums\ValueChangeActorType;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'tenant_code',
|
||||||
|
'trackable_type',
|
||||||
|
'trackable_id',
|
||||||
|
'attribute',
|
||||||
|
'old_value',
|
||||||
|
'new_value',
|
||||||
|
'changed_at',
|
||||||
|
'actor_type',
|
||||||
|
'user_id',
|
||||||
|
])]
|
||||||
|
class ValueChange extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
/** @return MorphTo<Model, $this> */
|
||||||
|
public function trackable(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<User, $this> */
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Tenant, $this> */
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'trackable_id' => 'integer',
|
||||||
|
'changed_at' => 'datetime',
|
||||||
|
'actor_type' => ValueChangeActorType::class,
|
||||||
|
'user_id' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ class MailTestService
|
|||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'message' => 'Correo de prueba enviado correctamente.',
|
'code' => 'mail.test_sent',
|
||||||
|
'message' => __('api.mail.test_sent'),
|
||||||
'recipient' => $recipient,
|
'recipient' => $recipient,
|
||||||
'tenant_code' => $tenant->codigo,
|
'tenant_code' => $tenant->codigo,
|
||||||
'mailer' => $mailService->mailerName(),
|
'mailer' => $mailService->mailerName(),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user