72 Commits

Author SHA1 Message Date
4a51f3afde feat(website-extras): enhance toggle functionality and update related tests; add additional info configuration to tenant extras 2026-08-03 09:10:50 -03:00
f1df6a5918 feat(seeder): update MenuSeeder and tests to reflect new admin menu structure; change border color in WebsiteTypeSeeder 2026-08-03 09:07:49 -03:00
dbeaf19513 feat(auth): implement AdminAppMeController and AdminAppMeResource; add routes and tests for user context retrieval 2026-07-31 16:11:47 -03:00
dd76d5d951 feat(website-type): add footer logo support in WebsiteType model, controller, and resource; update seeder and tests 2026-07-31 14:16:58 -03:00
f603a82b5e feat(website-type): implement BootstrapWebsiteTypeController and related request/resource for domain-based bootstrapping 2026-07-31 13:39:31 -03:00
b478c23f3b feat(website-type): add new color fields and update related methods in WebsiteType model and service 2026-07-31 12:55:40 -03:00
6858b387c3 feat(website-type): add 'dominio' field to WebsiteType model and update related tests 2026-07-31 12:06:27 -03:00
893bea1492 feat(website-type): add login_header_footer_color field and update related tests 2026-07-31 12:06:13 -03:00
ec1d80dff2 feat(seeder): update image paths in ProductCatalogFromImagesSeeder and TenantSeeder, add new catalog images 2026-07-31 11:45:29 -03:00
3968a10f6a feat(website-type): add presentation fields and site logo relationship to WebsiteType model 2026-07-31 11:39:17 -03:00
cf2beb2ff5 feat(auth): implement AdminApp login functionality with controller, request, routes, and tests 2026-07-31 10:47:55 -03:00
484fa204b3 feat(tenant): add BootstrapTenantController and corresponding tests for tenant bootstrapping functionality 2026-07-31 10:36:46 -03:00
6dd057874a feat(website-extras): add showExtra method and resource for individual website extras, update routes and tests 2026-07-31 09:51:27 -03:00
40cbac17c6 feat(website-extras): implement toggle functionality for website extras and update related tests 2026-07-31 09:47:56 -03:00
45e56c43a6 feat(website-extras): add 'is_enabled' column to websites_extras table and update model and tests 2026-07-31 09:38:30 -03:00
cbb7b1c3a1 feat(database): remove 'database_rules' from WebsiteTypeExtra configurations and related tests 2026-07-31 09:28:12 -03:00
6b3a12b624 feat(website-extras): refactor WebsiteExtra management to use 'codigo' for identification, update routes, requests, and services 2026-07-31 09:27:35 -03:00
40a7dece11 feat(authorization): implement WebsiteExtra management with controller, request, resource, service, routes, middleware, and tests 2026-07-31 09:19:25 -03:00
5ec65ce3d3 fix(authorization): rename pivot table from 'menues_roles' to 'roles_menues' and update related references 2026-07-31 08:35:45 -03:00
b7e1332b8c feat(authorization): refactor role management to use RoleCode enum, update migrations, seeders, and tests 2026-07-31 08:32:00 -03:00
a087a511e0 feat(authorization): filter tenant menus by user roles in bootstrap process 2026-07-31 08:23:18 -03:00
a20b1713d3 feat(authorization): establish many-to-many relationship between roles and menus, add migration and seeder updates 2026-07-30 16:54:25 -03:00
d56d387933 feat(authorization): add role and tenant codes to users, implement validation logic, and create tests 2026-07-30 16:37:37 -03:00
cba32e90e7 feat(authorization): implement AuthorizationSeeder with roles and permissions, add tests 2026-07-30 16:37:37 -03:00
a674bcfabc feat(authorization): add roles and permissions models, migration, and tests 2026-07-30 16:37:20 -03:00
ace3f136e4 refactor(menu): add admin menu items and corresponding test cases 2026-07-30 16:36:43 -03:00
2cc72be850 refactor(website): add 'dates_text' field to event configuration and update related tests 2026-07-29 16:23:24 -03:00
8e3ee7eff6 refactor(tenant): introduce TenantInformationService for loading tenant data and resolving website extras 2026-07-29 15:48:01 -03:00
27d4f9fac6 feat(tenant): implement website extras functionality and validation in tenant creation 2026-07-29 15:16:33 -03:00
9e3163b74f refactor(website): update relationships and migration to use 'website_type_code' instead of 'website_type' 2026-07-29 15:03:31 -03:00
b9ad2e589f refactor(tenant): remove legacy presentation configuration and related carousel images 2026-07-29 14:54:57 -03:00
5416c0fd61 feat(seeder): add WebsiteTypeSeeder and update DatabaseSeeder to include it 2026-07-29 14:42:03 -03:00
04bf03fc2e feat(website): add website type and extras models with migration and tests 2026-07-29 14:09:47 -03:00
7c9ebc6d4d feat(auth): implement login security features including account locking and login attempt tracking 2026-07-29 10:47:26 -03:00
45553dc514 feat(purchase): add review submission for pending purchases and update related logic 2026-07-29 09:11:00 -03:00
1c870d9cfa feat(tickets): add source_purchase_id to tickets and update related logic 2026-07-28 16:10:37 -03:00
8ab94ec61f fix(validation): improve uniqueness message for attribute validation 2026-07-28 15:51:43 -03:00
4eacff86bc feat(purchase): update customer data for pending payment purchases 2026-07-28 15:06:00 -03:00
5c457db1c9 Merge branch 'dev' of https://gitea.quo.ar/tbianchini/shopit-back into dev 2026-07-28 12:20:20 -03:00
8dc80411d7 feat(categories): add category retrieval and pagination for tenant-specific products 2026-07-28 12:20:17 -03:00
e16d91f3f9 feat(tenant): add categories relationship and structure to tenant resource and controller 2026-07-28 10:57:00 -03:00
89f6dab0c4 feat(seeders): associate categories with tenant codes in seeders and tests 2026-07-28 10:42:55 -03:00
ff9d7728e8 Implement localization for API responses and error messages
- Added localization support for API responses in English and Spanish.
- Introduced a middleware to set the API locale based on the Accept-Language header.
- Updated various exception messages and validation responses to use localized strings.
- Created new language files for English and Spanish translations.
- Refactored existing code to replace hardcoded messages with localized strings.
- Added tests to verify localization functionality and response correctness.
2026-07-28 10:19:39 -03:00
754aeebe3a Squashed commit of the following:
commit c993da3397b65488d919d1b929cbd4638754601b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Jul 28 09:54:10 2026 -0300

    feat(tickets): update ticket metadata layout for improved readability and clarity

commit 140fa4676d84bed788894e1d7994b26dccac7a34
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Jul 28 09:43:21 2026 -0300

    feat(tickets): enhance PDF ticket download with dynamic filename and improved styling

commit 8875c047b198776e61e8353c99cf4c7b00bc8fc9
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Jul 28 09:03:17 2026 -0300

    feat(tickets): add PDF download functionality for tickets

    - Implemented a new endpoint to download tickets as a PDF.
    - Created DownloadTicketsPdfRequest for validating ticket IDs.
    - Added TicketPdfService to handle PDF generation and QR code embedding.
    - Developed a Blade view for rendering ticket details in PDF format.
    - Updated API routes to include the new PDF download route.
    - Added tests to ensure authenticated users can download their tickets and that users cannot download tickets belonging to others.
    - Updated composer.json and composer.lock to include necessary dependencies for PDF generation and QR code creation.
2026-07-28 09:54:49 -03:00
5107d858cc fix(ticket): temporarily disable maximum use date validation in ticket generation 2026-07-27 17:01:08 -03:00
aa4f3303fc feat(variants): add minimum and maximum use dates to variants and update related logic 2026-07-27 14:59:07 -03:00
069867522a fix(env): update frontend URL variable and adjust CORS settings for improved origin handling 2026-07-27 14:09:43 -03:00
8a19b0136d fix(routes): update Google auth callback route to match expected path 2026-07-27 13:39:56 -03:00
8b26a2b63e feat(purchase): add checkout expiration settings and reservation status to purchase items
- Introduced a new configuration file for purchase settings, including checkout and payment expiration times.
- Added a migration to include a `reservation_status` column in the `compra_items` table, defaulting to 'active' and updating existing records based on purchase status.
- Created a migration to add an `expires_at` timestamp to the `compras` table, updating it for existing records based on their status.
- Scoped unique indexes in the `carritos` table to only active carts, adding virtual columns for active user and guest tokens.
- Implemented a console command to expire overdue purchases and release stock reservations, scheduled to run every minute.
- Added tests for Google token exchange and login functionality, ensuring proper cart handling and user authentication.
- Updated purchase-related tests to reflect changes in item handling and customer data validation.
2026-07-27 12:49:27 -03:00
c37b8894e4 Forgot password flow 2026-07-27 09:43:00 -03:00
7b565e7fa7 feat(routes): remove welcome route to streamline authentication flow 2026-07-24 15:17:18 -03:00
cb76d1c559 Implement code changes to enhance functionality and improve performance 2026-07-24 14:28:18 -03:00
135a3bea3e feat(search): implement catalog item search functionality with request validation and resource formatting 2026-07-24 14:17:34 -03:00
9e6923e732 feat(env): update environment configuration for Google OAuth and database connection 2026-07-24 11:18:40 -03:00
18c23a112a feat(seeder): update featured groups configuration with product and group layouts 2026-07-24 10:42:27 -03:00
c1be9ecf7f feat(menu): ensure label column is added only if it doesn't exist and simplify menu update logic 2026-07-24 10:13:18 -03:00
979ed428a2 feat(menu): add label field to menu model and update related functionality 2026-07-24 10:08:04 -03:00
7889d747d9 feat(menu): add account menu hierarchy and related tests in MenuSeeder and MenuSeederTest 2026-07-24 10:07:47 -03:00
f6ce85cbde feat(menu): update validation rules and structure for contact content in MenuSeeder and related tests 2026-07-24 09:41:13 -03:00
460eec3c78 feat(menu): implement menu hierarchy and static content for help section in MenuSeeder and related tests 2026-07-23 17:07:55 -03:00
2b1ef5f8a9 feat(menu): implement TenantMenuController, StoreTenantMenuRequest, and TenantMenuService for managing tenant-specific menus 2026-07-23 16:23:19 -03:00
5ada399adf feat(menu): enhance validation for content_type and static_content_schema in Menu model 2026-07-23 15:57:14 -03:00
aed818da6e feat(menu): enhance Menu and TenantMenu models with parent-child relationships and static content support 2026-07-23 15:54:16 -03:00
018f3f6c13 feat(seeder): add SocialMediaSeeder and integrate social media data into TenantSeeder 2026-07-23 15:19:25 -03:00
217f86f624 feat(social-media): add 'orden' field to social media pivot table and update related functionality 2026-07-23 15:19:17 -03:00
25891cefbc feat(tenant): add social media functionality with validation, synchronization, and tests 2026-07-23 15:03:57 -03:00
c66d2019d6 feat(social-media): add SocialMedia model, migration, and tests for tenant associations 2026-07-23 14:54:42 -03:00
12d496544d feat(tenant): add main carousel images to tenant seeder and implement corresponding tests 2026-07-23 14:53:00 -03:00
c31be9c927 feat(tenant): update validation rules for logos and main carousel images, enhance tests for carousel image upload and update 2026-07-23 14:20:26 -03:00
67ede1a2b4 feat(tenant): add main carousel images functionality with migration, model, and tests 2026-07-23 14:19:00 -03:00
e778d862ba feat(catalog): enhance featured groups with paginated and carousel layouts, update seeder and tests 2026-07-23 13:24:39 -03:00
1438ff66c7 feat(catalog): add GroupLayout enum and integrate into FeaturedGroup model, migrations, and seeders 2026-07-23 13:22:22 -03:00
246 changed files with 13473 additions and 1152 deletions

View File

@@ -3,11 +3,15 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
FRONTEND_URL=http://localhost:4200
INTEGRATION_SECRET=
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
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_MAINTENANCE_DRIVER=file
@@ -15,27 +19,38 @@ APP_MAINTENANCE_DRIVER=file
# PHP_CLI_SERVER_WORKERS=4
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost/auth/google/callback
BCRYPT_ROUNDS=12
AUTH_MAX_LOGIN_ATTEMPTS=5
AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
AUTH_LOGIN_LOCK_MINUTES=15
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=
INTEGRATION_SECRET=change-me
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
SANCTUM_STATEFUL_DOMAINS=localhost,127.0.0.1,*.shopit.com.ar
SANCTUM_EXPIRATION=720
SESSION_SECURE_COOKIE=false
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
@@ -51,27 +66,20 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
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_SCHEME=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ENDPOINT=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=garage
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=
AWS_HTTP_VERIFY=
AWS_USE_PATH_STYLE_ENDPOINT=false
AWS_HTTP_VERIFY=true
VITE_APP_NAME="${APP_NAME}"

View File

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

View File

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

View File

@@ -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)
);
}
}

View File

@@ -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);
}
}

View File

@@ -5,22 +5,47 @@ namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Requests\GoogleTokenExchangeRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\GoogleAuthService;
use App\Domains\Cart\Services\GuestCartMergeService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cookie;
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
{
$authentication = $this->googleAuthService->exchange($request->validated('oauth_code'));
$authentication = $this->googleAuthService->exchange(
$request->validated('oauth_code'),
$request->validated('tenant_codigo'),
);
return response()->json([
'message' => 'Sesion iniciada correctamente.',
$guestTokenCookie = $request->cookie('guest_token');
$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_type' => 'Bearer',
'user' => UserResource::make($authentication['user']),
]);
if ($guestToken !== null) {
$response->withCookie(Cookie::forget('guest_token'));
}
return $response;
}
}

View File

@@ -3,28 +3,30 @@
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Requests\LoginUserRequest;
use App\Domains\Auth\Models\User;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\PasswordLoginService;
use App\Domains\Cart\Services\GuestCartMergeService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Facades\Cookie;
class LoginController extends Controller
{
/**
* @throws ValidationException
*/
public function __construct(
private readonly GuestCartMergeService $guestCartMergeService,
private readonly PasswordLoginService $passwordLoginService,
) {}
public function __invoke(LoginUserRequest $request): JsonResponse
{
$credentials = $request->validated();
$user = User::query()->where('email', $credentials['email'])->first();
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => 'Email o Contraseña incorrectos.',
]);
}
$user = $this->passwordLoginService->authenticate(
$credentials['email'],
$credentials['password'],
$credentials['tenant_codigo'],
$request->ip(),
$request->userAgent(),
);
$expirationMinutes = (int) config('sanctum.expiration');
$token = $user->createToken(
@@ -33,11 +35,28 @@ class LoginController extends Controller
now()->addMinutes($expirationMinutes),
)->plainTextToken;
return response()->json([
'message' => 'Sesion iniciada correctamente.',
$guestTokenCookie = $request->cookie('guest_token');
$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_type' => 'Bearer',
'user' => UserResource::make($user),
]);
if ($guestToken !== null) {
$response->withCookie(Cookie::forget('guest_token'));
}
return $response;
}
}

View File

@@ -23,7 +23,8 @@ class LogoutController extends Controller
}
return response()->json([
'message' => 'Sesion cerrada correctamente.',
'code' => 'auth.logout_success',
'message' => __('api.auth.logout_success'),
]);
}
}

View File

@@ -12,15 +12,17 @@ class RegisterController extends Controller
{
public function __construct(
protected RegisterUserService $registerUserService,
) {
}
) {}
public function __invoke(RegisterUserRequest $request): JsonResponse
{
$user = $this->registerUserService->register($request->validated());
return UserResource::make($user)
->additional(['message' => 'Usuario registrado correctamente.'])
->additional([
'code' => 'auth.register_success',
'message' => __('api.auth.register_success'),
])
->response()
->setStatusCode(201);
}

View 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,
]);
}
}

View File

@@ -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,
]);
}
}

View 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));
}
}

View 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',
];
}
}

View 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', '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);
}
}

View File

@@ -2,26 +2,63 @@
namespace App\Domains\Auth\Models;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Role;
use App\Domains\Tenant\Models\Tenant;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id'])]
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
protected $attributes = [
'rol_codigo' => RoleCode::User->value,
];
protected static function newFactory(): UserFactory
{
return UserFactory::new();
}
/** @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 array<string, string>
*/
@@ -30,6 +67,9 @@ class User extends Authenticatable
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'failed_login_attempts' => 'integer',
'last_failed_login_at' => 'datetime',
'locked_until' => 'datetime',
];
}
}

View File

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

View File

@@ -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'],
];
}
}

View File

@@ -16,6 +16,7 @@ class GoogleTokenExchangeRequest extends FormRequest
{
return [
'oauth_code' => ['required', 'uuid'],
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class LoginUserRequest extends FormRequest
{
@@ -11,6 +12,17 @@ class LoginUserRequest extends FormRequest
return true;
}
protected function prepareForValidation(): void
{
$email = $this->input('email');
if (is_string($email)) {
$this->merge([
'email' => Str::lower(trim($email)),
]);
}
}
/**
* @return array<string, mixed>
*/
@@ -19,6 +31,7 @@ class LoginUserRequest extends FormRequest
return [
'email' => ['required', 'string', 'email', 'max:255'],
'password' => ['required', 'string'],
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
];
}
}

View 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(),
],
];
}
}

View File

@@ -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}$/'],
];
}
}

View 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),
];
}
}

View File

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

View File

@@ -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;
}
}

View File

@@ -25,7 +25,7 @@ class GoogleAuthService
if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) {
throw ValidationException::withMessages([
'tenant' => 'El tenant o la URL de retorno no son validos.',
'tenant' => __('api.auth.invalid_tenant_or_return_url'),
]);
}
@@ -50,13 +50,13 @@ class GoogleAuthService
$context = Str::isUuid($state) ? Cache::pull("google-oauth-context:{$state}") : null;
if (! is_array($context) || ! isset($context['tenant_codigo'], $context['return_url'])) {
abort(400, 'La solicitud de autenticacion expiro. Intenta nuevamente.');
abort(400, __('api.auth.request_expired'));
}
$tenant = Tenant::query()->where('codigo', $context['tenant_codigo'])->first();
if (! $tenant || ! $this->isTenantReturnUrl($context['return_url'], $tenant)) {
abort(400, 'La URL de retorno no es valida.');
abort(400, __('api.auth.invalid_return_url'));
}
/** @var SocialiteUser $googleUser */
@@ -72,6 +72,7 @@ class GoogleAuthService
Cache::put("google-oauth-exchange:{$exchangeCode}", [
'user_id' => $user->id,
'token' => $token,
'tenant_codigo' => $tenant->codigo,
], now()->addMinutes(5));
return redirect()->to($context['return_url'].'/login?'.http_build_query([
@@ -79,21 +80,28 @@ class GoogleAuthService
]));
}
/** @return array{user: User, token: string} */
public function exchange(string $exchangeCode): array
/** @return array{user: User, token: string, tenant_codigo: string} */
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}");
if (! $authentication) {
throw ValidationException::withMessages([
'oauth_code' => 'El codigo de autenticacion expiro o ya fue utilizado.',
'oauth_code' => __('api.auth.oauth_code_expired'),
]);
}
if (($authentication['tenant_codigo'] ?? null) !== $tenantCodigo) {
throw ValidationException::withMessages([
'tenant_codigo' => __('api.auth.tenant_mismatch'),
]);
}
return [
'user' => User::query()->findOrFail($authentication['user_id']),
'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)) {
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;
if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) {
throw ValidationException::withMessages([
'google' => 'La cuenta de Google debe tener el email verificado.',
'google' => __('api.auth.google_email_unverified'),
]);
}

View File

@@ -0,0 +1,224 @@
<?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\Validation\ValidationException;
class PasswordLoginService
{
/**
* @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);
}
$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): void
{
$windowMinutes = max(1, (int) config('login-security.attempt_window_minutes'));
$maxAttempts = max(1, (int) config('login-security.max_attempts'));
$lockMinutes = max(1, (int) config('login-security.lock_minutes'));
$withinAttemptWindow = $user->last_failed_login_at !== null
&& $user->last_failed_login_at->gte($now->subMinutes($windowMinutes));
$attempts = $withinAttemptWindow
? $user->failed_login_attempts + 1
: 1;
$user->forceFill([
'failed_login_attempts' => $attempts,
'last_failed_login_at' => $now,
'locked_until' => $attempts >= $maxAttempts
? $now->addMinutes($lockMinutes)
: null,
])->save();
}
private function recordAttempt(
?User $user,
string $normalizedEmail,
?string $tenantCode,
string $outcome,
?string $ipAddress,
?string $userAgent,
): void {
LoginAttempt::query()->create([
'user_id' => $user?->getKey(),
'email_fingerprint' => hash_hmac(
'sha256',
$normalizedEmail,
(string) config('app.key'),
),
'tenant_codigo' => $tenantCode,
'outcome' => $outcome,
'ip_address' => $ipAddress,
'user_agent' => $userAgent === null
? null
: mb_substr($userAgent, 0, 1024),
]);
}
}

View File

@@ -0,0 +1,180 @@
<?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): void
{
$emailFingerprint = $this->emailFingerprint($email);
try {
$attemptId = DB::transaction(function () use ($email, $emailFingerprint): ?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(),
'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);
}
}

View 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);
});

View File

@@ -1,16 +1,27 @@
<?php
use App\Domains\Auth\Controllers\CreateResetPasswordAttemptController;
use App\Domains\Auth\Controllers\GoogleTokenExchangeController;
use App\Domains\Auth\Controllers\LoginController;
use App\Domains\Auth\Controllers\LogoutController;
use App\Domains\Auth\Controllers\MeController;
use App\Domains\Auth\Controllers\RegisterController;
use App\Domains\Auth\Controllers\ResetPasswordController;
use App\Domains\Auth\Controllers\UpdateProfileController;
use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
use Illuminate\Support\Facades\Route;
Route::post('/register', RegisterController::class);
Route::post('/login', LoginController::class);
Route::post('/login', LoginController::class)->middleware('throttle:login');
Route::post('/password/reset-attempts', CreateResetPasswordAttemptController::class)
->middleware('throttle:5,1');
Route::post('/password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
->middleware('throttle:10,1');
Route::post('/password/reset', ResetPasswordController::class)
->middleware('throttle:5,1');
Route::post('/auth/google/exchange', GoogleTokenExchangeController::class);
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
Route::middleware('auth:sanctum')->get('/me', MeController::class);
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
require __DIR__.'/adminapp.php';

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,7 +36,10 @@ class CartController extends Controller
);
$response = CartResource::make($result['cart'])
->additional(['message' => 'Producto agregado al carrito.'])
->additional([
'code' => 'cart.item_added',
'message' => __('api.cart.item_added'),
])
->response();
if ($result['guest_token'] !== null) {
@@ -58,13 +61,19 @@ class CartController extends Controller
$cartItem->getKey(),
(int) $request->validated('cantidad'),
)
)->additional(['message' => 'Cantidad de producto actualizada.']);
)->additional([
'code' => 'cart.quantity_updated',
'message' => __('api.cart.quantity_updated'),
]);
}
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
{
return CartResource::make(
$this->cartService->removeItem($tenant, $request, $cartItem->getKey())
)->additional(['message' => 'Producto eliminado del carrito.']);
)->additional([
'code' => 'cart.item_removed',
'message' => __('api.cart.item_removed'),
]);
}
}

View File

@@ -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;
}
}

View File

@@ -79,7 +79,7 @@ class Cart extends Model
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => 'La cantidad debe ser mayor a cero.',
'cantidad' => __('api.cart.positive_quantity'),
]);
}
@@ -91,7 +91,7 @@ class Cart extends Model
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
'cantidad' => "Stock insuficiente para el producto solicitado. Maximo disponible: {$availableQuantity}.",
'cantidad' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
]);
}
@@ -123,7 +123,7 @@ class Cart extends Model
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => 'La cantidad debe ser mayor a cero.',
'cantidad' => __('api.cart.positive_quantity'),
]);
}
@@ -146,7 +146,7 @@ class Cart extends Model
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
$maxAvailable = $availableQuantity + $item->cantidad;
throw ValidationException::withMessages([
'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.",
'cantidad' => __('api.cart.max_quantity', ['max' => $maxAvailable]),
]);
}
@@ -209,13 +209,13 @@ class Cart extends Model
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
'variant_id' => 'Un bundle no admite una variante.',
'variant_id' => __('api.cart.bundle_variant_forbidden'),
]);
}
if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([
'catalog_item_id' => 'El bundle no tiene componentes.',
'catalog_item_id' => __('api.cart.empty_bundle'),
]);
}
@@ -225,7 +225,7 @@ class Cart extends Model
if ($variantId === null) {
if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([
'variant_id' => 'Debe seleccionar una variante para este ítem.',
'variant_id' => __('api.cart.variant_required'),
]);
}

View File

@@ -172,6 +172,7 @@ class CartService
{
return Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('status', 'active')
->when(
$identity['user_id'] !== null,
fn ($query) => $query->where('user_id', $identity['user_id']),
@@ -199,7 +200,10 @@ class CartService
*/
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) {
$attributes['user_id'] = $identity['user_id'];
@@ -207,6 +211,6 @@ class CartService
$attributes['guest_token'] = $identity['guest_token'];
}
return Cart::query()->firstOrCreate($attributes, ['status' => 'active']);
return Cart::query()->firstOrCreate($attributes);
}
}

View 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,
]);
});
}
}

View File

@@ -4,7 +4,6 @@ use App\Domains\Cart\Controllers\CartController;
use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')
->middleware(\App\Domains\Cart\Middleware\MergeGuestCartMiddleware::class)
->group(function (): void {
Route::get('cart', [CartController::class, 'show']);
Route::post('cart/items', [CartController::class, 'addItem']);

View File

@@ -2,19 +2,27 @@
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\FeaturedItem;
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
use App\Domains\Catalog\Requests\CategoryPageRequest;
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
use App\Domains\Catalog\Resources\CatalogItemResource;
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
class CatalogController extends Controller
@@ -36,6 +44,47 @@ class CatalogController extends Controller
));
}
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(
FeaturedGroupPageRequest $request,
Tenant $tenant,
@@ -81,9 +130,19 @@ class CatalogController extends Controller
->setStatusCode(201);
}
/** @return array<string, mixed> */
/** @return array<array-key, mixed> */
private function featuredItemsResponse(FeaturedGroup $featuredGroup, int $page): array
{
if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
$featuredItems = $this->featuredItemsQuery($featuredGroup)->get();
$featuredItems->each(
fn ($featuredItem) => $featuredItem->setRelation('featuredGroup', $featuredGroup)
);
return CatalogFeaturedItemResource::collection($featuredItems)->resolve();
}
$paginator = $this->paginateFeaturedItems($featuredGroup, $page);
$paginator->getCollection()->each(
@@ -99,17 +158,7 @@ class CatalogController extends Controller
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(
$paginator = $this->featuredItemsQuery($featuredGroup)->paginate(
perPage: self::ITEMS_PER_PAGE,
pageName: 'page',
page: $page,
@@ -120,4 +169,18 @@ class CatalogController extends Controller
'featuredGroup' => $featuredGroup->id,
]));
}
/** @return HasMany<FeaturedItem, FeaturedGroup> */
private function featuredItemsQuery(FeaturedGroup $featuredGroup): HasMany
{
return $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',
]);
}
}

View 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');
}
}

View File

@@ -7,6 +7,7 @@ use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Tenant\Models\Tenant;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -163,6 +164,16 @@ class CatalogItem extends Model
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
{
return $this->type === CatalogItemType::Bundle;

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
@@ -13,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_code',
'product_layout',
'group_layout',
'group_name',
'group_order',
])]
@@ -28,6 +30,7 @@ class FeaturedGroup extends Model
{
return [
'product_layout' => ProductLayout::class,
'group_layout' => GroupLayout::class,
'group_order' => 'integer',
];
}

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -13,6 +14,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'catalog_item_id',
'inventory_id',
'minimum_use_date',
'maximum_use_date',
])]
class Variant extends Model
{
@@ -27,6 +30,8 @@ class Variant extends Model
return [
'catalog_item_id' => 'integer',
'inventory_id' => 'integer',
'minimum_use_date' => 'datetime',
'maximum_use_date' => 'datetime',
];
}
@@ -89,4 +94,16 @@ class Variant extends Model
return $definitions === '' ? $name : "{$name} ({$definitions})";
}
public function getMinimumUseDate(): ?CarbonInterface
{
return $this->minimum_use_date
?? $this->catalogItem->getMinimumUseDate();
}
public function getMaximumUseDate(): ?CarbonInterface
{
return $this->maximum_use_date
?? $this->catalogItem->getMaximumUseDate();
}
}

View 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'],
];
}
}

View 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())]);
}
}
}

View File

@@ -76,6 +76,13 @@ class StoreCatalogItemRequest extends FormRequest
'variants.*.inventory_id' => ['prohibited'],
'variants.*.reserved_stock' => ['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.*' => ['nullable', 'string'],
'variants.*.images' => ['sometimes', 'array'],

View File

@@ -9,8 +9,8 @@ use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin FeaturedGroup */
class CatalogFeaturedGroupResource extends JsonResource
{
/** @param array<string, mixed> $itemsPage */
public function __construct($resource, private readonly array $itemsPage)
/** @param array<array-key, mixed> $items */
public function __construct($resource, private readonly array $items)
{
parent::__construct($resource);
}
@@ -22,8 +22,9 @@ class CatalogFeaturedGroupResource extends JsonResource
'id' => $this->id,
'title' => $this->group_name,
'layout' => $this->product_layout->value,
'group_layout' => $this->group_layout->value,
'group_order' => $this->group_order,
'items' => $this->itemsPage,
'items' => $this->items,
];
}
}

View File

@@ -111,6 +111,12 @@ class CatalogItemDetailResource extends JsonResource
return [
'id' => $variant->id,
'stock_tecnico' => $this->variantStock($variant),
'minimum_use_date' => $variant->minimum_use_date,
'maximum_use_date' => $variant->maximum_use_date,
'effective_minimum_use_date' => $variant->minimum_use_date
?? $this->minimum_use_date,
'effective_maximum_use_date' => $variant->maximum_use_date
?? $this->maximum_use_date,
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value,

View File

@@ -33,6 +33,12 @@ class CatalogItemResource extends JsonResource
->map(fn ($variant) => [
'id' => $variant->id,
'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->minimum_use_date
?? $this->minimum_use_date,
'effective_maximum_use_date' => $variant->maximum_use_date
?? $this->maximum_use_date,
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value,

View File

@@ -0,0 +1,45 @@
<?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,
'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(),
];
}
}

View File

@@ -7,9 +7,13 @@ use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@@ -42,7 +46,7 @@ class CatalogService
} else {
if (array_key_exists('components', $data)) {
throw ValidationException::withMessages([
'components' => ['Un item standard no puede tener componentes.'],
'components' => [__('api.catalog.standard_with_components')],
]);
}
@@ -158,6 +162,78 @@ class CatalogService
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.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.definitions.itemAttribute.attribute',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
])
->orderBy('nombre')
->paginate(perPage: $perPage, pageName: 'page', page: $page);
}
public function delete(CatalogItem $catalogItem): void
{
DB::transaction(function () use ($catalogItem): void {
@@ -213,7 +289,7 @@ class CatalogService
if (isset($seen[$key])) {
throw ValidationException::withMessages([
"components.{$index}" => ['El componente esta duplicado.'],
"components.{$index}" => [__('api.catalog.duplicate_component')],
]);
}
$seen[$key] = true;
@@ -226,7 +302,7 @@ class CatalogService
if ($componentItem === null) {
throw ValidationException::withMessages([
"components.{$index}.catalog_item_id" => [
'El item no pertenece al tenant del bundle.',
__('api.catalog.component_wrong_tenant'),
],
]);
}
@@ -234,7 +310,7 @@ class CatalogService
if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) {
throw ValidationException::withMessages([
"components.{$index}.catalog_item_id" => [
'El componente debe ser un item standard distinto del bundle.',
__('api.catalog.invalid_component'),
],
]);
}
@@ -243,7 +319,7 @@ class CatalogService
if ($hasVariants && $variantId === null) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
'Debe seleccionar una variante para este componente.',
__('api.catalog.component_variant_required'),
],
]);
}
@@ -251,7 +327,7 @@ class CatalogService
if (! $hasVariants && $variantId !== null) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
'El componente con inventario directo no admite una variante.',
__('api.catalog.component_variant_forbidden'),
],
]);
}
@@ -259,7 +335,7 @@ class CatalogService
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
'La variante no pertenece al componente indicado.',
__('api.catalog.component_variant_invalid'),
],
]);
}
@@ -281,7 +357,7 @@ class CatalogService
{
if ($components === []) {
throw ValidationException::withMessages([
'components' => ['Un bundle debe tener al menos un componente.'],
'components' => [__('api.catalog.bundle_component_required')],
]);
}
@@ -296,7 +372,7 @@ class CatalogService
] as $field) {
if (array_key_exists($field, $data)) {
throw ValidationException::withMessages([
$field => ["{$field} no se admite para un bundle."],
$field => [__('api.catalog.bundle_field_forbidden', ['field' => $field])],
]);
}
}
@@ -324,7 +400,7 @@ class CatalogService
if ($attachment === null) {
throw ValidationException::withMessages([
$validationKey => ['El attachment indicado no existe.'],
$validationKey => [__('api.catalog.attachment_not_found')],
]);
}
@@ -356,7 +432,7 @@ class CatalogService
if ($attribute === null) {
throw ValidationException::withMessages([
'attribute_codes' => [
"El atributo {$attributeCode} no existe para el tenant del ítem.",
__('api.catalog.attribute_not_found', ['attribute' => $attributeCode]),
],
]);
}
@@ -390,7 +466,7 @@ class CatalogService
) {
throw ValidationException::withMessages([
"variants.{$index}.inventory" => [
'inventory_id, reserved_stock y sold_units son administrados internamente.',
__('api.catalog.managed_inventory_fields'),
],
]);
}
@@ -398,7 +474,12 @@ class CatalogService
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
$variant = $catalogItem->variants()->create([
'inventory_id' => $inventory->id,
'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) {
$itemAttribute = $itemAttributes[$attributeCode] ?? null;
@@ -406,7 +487,7 @@ class CatalogService
if ($itemAttribute === null) {
throw ValidationException::withMessages([
"variants.{$index}.values.{$attributeCode}" => [
'El atributo no pertenece al ítem de catálogo.',
__('api.catalog.attribute_not_on_item'),
],
]);
}
@@ -420,6 +501,24 @@ class CatalogService
return $variant;
}
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<int, array<string, mixed>> $variants
@@ -433,7 +532,7 @@ class CatalogService
if ($hasVariants && $variants === []) {
throw ValidationException::withMessages([
'variants' => [
'Un ítem con attribute_codes debe tener variantes.',
__('api.catalog.variants_required'),
],
]);
}
@@ -441,7 +540,7 @@ class CatalogService
if (! $hasVariants && $variants !== []) {
throw ValidationException::withMessages([
'variants' => [
'Un ítem sin attribute_codes no puede tener variantes.',
__('api.catalog.variants_forbidden'),
],
]);
}
@@ -449,7 +548,7 @@ class CatalogService
if ($hasVariants && $hasDirectStock) {
throw ValidationException::withMessages([
'real_stock' => [
'Un ítem con variantes no puede tener inventario directo.',
__('api.catalog.direct_inventory_forbidden'),
],
]);
}
@@ -461,7 +560,7 @@ class CatalogService
) {
throw ValidationException::withMessages([
'inventory' => [
'inventory_id, reserved_stock y sold_units son administrados internamente.',
__('api.catalog.managed_inventory_fields'),
],
]);
}

View File

@@ -7,6 +7,10 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
Route::get('catalog', [CatalogController::class, 'index']);
Route::get('catalog/featured-groups/{featuredGroup}/items', [CatalogController::class, 'featuredGroupItems'])
->name('catalog.featured-groups.items.index');
Route::get('catalog-items', [CatalogController::class, 'search'])
->name('catalog-items.index');
Route::get('categories/{category}', [CatalogController::class, 'category'])
->name('categories.show');
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
Route::post('catalog-items', [CatalogController::class, 'store']);
});

View File

@@ -6,17 +6,11 @@ use App\Domains\Integration\Requests\TelepagosWebhookRequest;
use App\Domains\Integration\Services\TelepagosWebhookService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TelepagosWebhookController extends Controller
{
/**
* Handle the incoming Telepagos webhook.
*
* @param TelepagosWebhookRequest $request
* @param string $tenantCodigo
* @param TelepagosWebhookService $service
* @return JsonResponse
*/
public function handle(TelepagosWebhookRequest $request, string $tenantCodigo, TelepagosWebhookService $service): JsonResponse
{
@@ -27,7 +21,11 @@ class TelepagosWebhookController extends Controller
return response()->json(['status' => 'success']);
} catch (\Exception $e) {
return response()->json(['status' => 'error', 'message' => $e->getMessage()], 500);
return response()->json([
'status' => 'error',
'code' => 'integration.webhook_failed',
'message' => __('api.integration.webhook_failed'),
], 500);
}
}
}

View File

@@ -26,7 +26,10 @@ class TenantIntegrationController extends Controller
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
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);
@@ -44,11 +47,13 @@ class TenantIntegrationController extends Controller
);
return response()->json([
'message' => 'integration configured correctly',
'code' => 'integration.configured',
'message' => __('api.integration.configured'),
]);
} catch (\Exception $e) {
return response()->json([
'message' => 'Error validando la configuración: ' . $e->getMessage()
'code' => 'integration.validation_failed',
'message' => __('api.integration.validation_failed', ['error' => $e->getMessage()]),
], 400);
}
}

View File

@@ -22,7 +22,7 @@ class StoreTenantIntegrationRequest extends FormRequest
if (! $this->integrationModel) {
throw ValidationException::withMessages([
'integration_code' => 'Integration not found.'
'integration_code' => __('api.integration.not_configured'),
]);
}
}

View File

@@ -8,6 +8,7 @@ use App\Domains\Purchase\Models\TelepagosQr;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TelepagosWebhookService
@@ -19,16 +20,13 @@ class TelepagosWebhookService
/**
* Handle the Telepagos webhook notification.
*
* @param string $tenantCodigo
* @param string $cashinId
* @return void
* @throws Exception
*/
public function handleWebhook(string $tenantCodigo, string $cashinId): void
{
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
$telepagosService = new TelepagosIntegrationService();
$telepagosService = new TelepagosIntegrationService;
$telepagosService->forTenant($tenant->codigo);
try {
@@ -48,6 +46,7 @@ class TelepagosWebhookService
if (! $cuit) {
Log::warning("Telepagos webhook: CUIT not found for Transferencia cashin {$cashinId}");
return;
}
@@ -55,19 +54,28 @@ class TelepagosWebhookService
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
->where('transfer_payer_dni', $dni)
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
])
->where('payment_method', 'transfer')
->where('total', $amount)
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [
Purchase::STATUS_IN_REVIEW,
])
->latest()
->first();
if (! $compra) {
Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}");
return;
}
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
if (! $qrOrderId) {
Log::warning("Telepagos webhook: qr_order_id not found for QR cashin {$cashinId}");
return;
}
@@ -75,6 +83,7 @@ class TelepagosWebhookService
if (! $telepagosQr) {
Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}");
return;
}
@@ -82,11 +91,16 @@ class TelepagosWebhookService
if (! $compra) {
Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}");
return;
}
if ($compra->status !== Purchase::STATUS_PENDING_PAYMENT) {
if (! in_array($compra->status, [
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
], true)) {
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");
return;
}
@@ -94,10 +108,12 @@ class TelepagosWebhookService
if ($amount !== $totalAmount) {
Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}");
return;
}
} else {
Log::warning("Telepagos webhook: Unknown operation_id {$operationId} for cashin {$cashinId}");
return;
}
@@ -114,7 +130,7 @@ class TelepagosWebhookService
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
];
\Illuminate\Support\Facades\DB::transaction(function () use ($compra, $paymentData) {
DB::transaction(function () use ($compra, $paymentData) {
TelepagosPayment::create($paymentData);
$this->checkoutService->confirmPurchase($compra);
$compra->markAsPaid();
@@ -122,7 +138,7 @@ class TelepagosWebhookService
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");
} catch (Exception $e) {
Log::error("Telepagos webhook error: " . $e->getMessage());
Log::error('Telepagos webhook error: '.$e->getMessage());
throw $e;
}
}

View File

@@ -24,7 +24,8 @@ class MailTestService
);
return [
'message' => 'Correo de prueba enviado correctamente.',
'code' => 'mail.test_sent',
'message' => __('api.mail.test_sent'),
'recipient' => $recipient,
'tenant_code' => $tenant->codigo,
'mailer' => $mailService->mailerName(),

View File

@@ -4,14 +4,16 @@ namespace App\Domains\Menu\Controllers;
use App\Domains\Menu\Models\Menu;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class MenuController extends Controller
{
public function index(): JsonResponse
{
$menues = Menu::all();
return response()->json($menues);
}
@@ -19,10 +21,23 @@ class MenuController extends Controller
{
$validated = $request->validate([
'code' => 'required|string|unique:menues,code',
'label' => 'required|string|max:255',
'parent_menu_code' => [
'nullable',
'string',
Rule::exists('menues', 'code'),
'different:code',
],
'content_type' => [
'sometimes',
Rule::in([Menu::CONTENT_TYPE_STATIC, Menu::CONTENT_TYPE_DYNAMIC]),
],
'static_content_schema' => 'required_if:content_type,static|nullable|array',
'route' => 'required|string',
]);
$menu = Menu::create($validated);
return response()->json($menu, 201);
}
@@ -35,16 +50,30 @@ class MenuController extends Controller
{
$validated = $request->validate([
'code' => 'sometimes|required|string|unique:menues,code,'.$menu->id,
'label' => 'sometimes|required|string|max:255',
'parent_menu_code' => [
'nullable',
'string',
Rule::exists('menues', 'code'),
Rule::notIn([$menu->code]),
],
'content_type' => [
'sometimes',
Rule::in([Menu::CONTENT_TYPE_STATIC, Menu::CONTENT_TYPE_DYNAMIC]),
],
'static_content_schema' => 'required_if:content_type,static|nullable|array',
'route' => 'sometimes|required|string',
]);
$menu->update($validated);
return response()->json($menu);
}
public function destroy(Menu $menu): JsonResponse
{
$menu->delete();
return response()->json(null, 204);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Menu\Controllers;
use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Requests\StoreTenantMenuRequest;
use App\Domains\Menu\Services\TenantMenuService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class TenantMenuController extends Controller
{
public function __construct(private readonly TenantMenuService $tenantMenuService) {}
public function store(
StoreTenantMenuRequest $request,
string $tenantCode,
string $menuCode,
): JsonResponse {
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
$menu = Menu::query()->where('code', $menuCode)->firstOrFail();
$tenantMenu = $this->tenantMenuService->configure(
$tenant,
$menu,
$request->validated('static_content'),
);
return response()->json($tenantMenu);
}
}

View File

@@ -2,31 +2,93 @@
namespace App\Domains\Menu\Models;
use App\Domains\Authorization\Models\Role;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Validation\ValidationException;
class Menu extends Model
{
use HasFactory;
public const CONTENT_TYPE_DYNAMIC = 'dynamic';
public const CONTENT_TYPE_STATIC = 'static';
protected $table = 'menues';
protected $attributes = [
'content_type' => self::CONTENT_TYPE_DYNAMIC,
];
protected $fillable = [
'code',
'label',
'parent_menu_code',
'content_type',
'static_content_schema',
'route',
];
protected function casts(): array
{
return [
'static_content_schema' => 'array',
];
}
protected static function booted(): void
{
static::saving(function (self $menu): void {
if (
$menu->content_type === self::CONTENT_TYPE_STATIC
&& empty($menu->static_content_schema)
) {
throw ValidationException::withMessages([
'static_content_schema' => __('api.menu.schema_required'),
]);
}
});
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_menu_code', 'code');
}
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_menu_code', 'code');
}
public function tenants(): BelongsToMany
{
return $this->belongsToMany(
Tenant::class,
'tenant_menues',
'tenants_menues',
'menu_code',
'tenant_codigo',
'tenant_code',
'code',
'codigo'
)->withTimestamps();
)
->using(TenantMenu::class)
->withPivot('static_content')
->withTimestamps();
}
public function roles(): BelongsToMany
{
return $this->belongsToMany(
Role::class,
'roles_menues',
'menu_codigo',
'rol_codigo',
'code',
'codigo'
)->using(MenuRole::class);
}
}

View File

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

View File

@@ -2,9 +2,36 @@
namespace App\Domains\Menu\Models;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Pivot;
class TenantMenu extends Pivot
{
protected $table = 'tenant_menues';
protected $table = 'tenants_menues';
public $incrementing = true;
protected $fillable = [
'tenant_code',
'menu_code',
'static_content',
];
protected function casts(): array
{
return [
'static_content' => 'array',
];
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
public function menu(): BelongsTo
{
return $this->belongsTo(Menu::class, 'menu_code', 'code');
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Domains\Menu\Requests;
use App\Domains\Menu\Models\Menu;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
class StoreTenantMenuRequest extends FormRequest
{
private ?Menu $menuModel = null;
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->menuModel = Menu::query()
->where('code', $this->route('menu_code'))
->first();
if (! $this->menuModel) {
throw ValidationException::withMessages([
'menu_code' => __('api.menu.not_found'),
]);
}
}
public function rules(): array
{
if ($this->menuModel?->content_type !== Menu::CONTENT_TYPE_STATIC) {
return [
'static_content' => ['nullable', 'array'],
];
}
$rules = [
'static_content' => ['required', 'array'],
];
foreach ($this->menuModel->static_content_schema as $field => $rule) {
$rules["static_content.{$field}"] = $rule;
}
return $rules;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Menu\Services;
use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Models\TenantMenu;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
class TenantMenuService
{
public function configure(Tenant $tenant, Menu $menu, ?array $staticContent): TenantMenu
{
return DB::transaction(fn () => TenantMenu::query()->updateOrCreate(
[
'tenant_code' => $tenant->codigo,
'menu_code' => $menu->code,
],
[
'static_content' => $menu->content_type === Menu::CONTENT_TYPE_STATIC
? $staticContent
: null,
],
));
}
}

View File

@@ -1,6 +1,12 @@
<?php
use App\Domains\Menu\Controllers\MenuController;
use App\Domains\Menu\Controllers\TenantMenuController;
use Illuminate\Support\Facades\Route;
Route::apiResource('menues', MenuController::class);
Route::post(
'{tenant_code}/menues/{menu_code}',
[TenantMenuController::class, 'store']
);

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Notification\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PasswordResetRequested
{
use Dispatchable, SerializesModels;
public function __construct(
public readonly int $attemptId,
public readonly string $tenantCode,
) {}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domains\Notification\Listeners;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
use Throwable;
class SendPasswordResetEmail implements ShouldQueueAfterCommit
{
use InteractsWithQueue;
public string $queue = 'emails';
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [30, 120, 300];
public function handle(PasswordResetRequested $event): void
{
try {
app(NotificationMailService::class)->sendPasswordResetCode(
$event->attemptId,
$event->tenantCode,
);
} catch (Throwable $exception) {
Log::error('Failed to send password reset email.', [
'attempt_id' => $event->attemptId,
'tenant_code' => $event->tenantCode,
'exception' => $exception,
]);
throw $exception;
}
}
}

View File

@@ -2,12 +2,14 @@
namespace App\Domains\Notification\Services;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Integration\Services\MailService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class NotificationMailService
{
@@ -29,6 +31,32 @@ class NotificationMailService
);
}
public function sendPasswordResetCode(int $attemptId, string $tenantCode): void
{
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
$attempt = ResetPasswordAttempt::query()
->with('user')
->findOrFail($attemptId);
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
Log::warning('Password reset email was skipped because the attempt is no longer pending.', [
'attempt_id' => $attemptId,
'tenant_code' => $tenantCode,
'attempt_status' => $attempt->status,
]);
return;
}
$this->mailService
->forTenant($tenantCode)
->send(
$attempt->user->email,
"Código para recuperar tu contraseña - {$tenant->nombre}",
view('mail.notifications.password-reset', compact('tenant', 'attempt'))->render(),
);
}
public function sendPurchasePaid(int $purchaseId): void
{
$purchase = Purchase::query()

View File

@@ -4,16 +4,19 @@ namespace App\Domains\Purchase\Controllers;
use App\Domains\Integration\Services\TelepagosIntegrationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Requests\PaymentIntentRequest;
use App\Domains\Purchase\Requests\StorePurchaseRequest;
use App\Domains\Purchase\Requests\StartCheckoutRequest;
use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest;
use App\Domains\Purchase\Requests\UpdatePurchaseItemQuantityRequest;
use App\Domains\Purchase\Resources\PurchaseResource;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class PurchaseController extends Controller
@@ -32,8 +35,11 @@ class PurchaseController extends Controller
)->response();
}
public function store(StorePurchaseRequest $request, Tenant $tenant, CheckoutService $checkoutService): JsonResponse
{
public function startCheckout(
StartCheckoutRequest $request,
Tenant $tenant,
CheckoutService $checkoutService,
): JsonResponse {
$data = $request->validated();
$purchase = $checkoutService->startCheckout(
@@ -49,14 +55,54 @@ class PurchaseController extends Controller
{
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
$compra->loadMissing(['items', 'cart.items']);
$compra->loadMissing('items')->loadCount('tickets');
$compra->items->load('imageAttachment');
if ($compra->cart !== null) {
$this->loadCartCatalogEntries($compra->cart->items);
return PurchaseResource::make($compra);
}
return PurchaseResource::make($compra);
public function updateCustomerData(
UpdatePurchaseCustomerRequest $request,
Tenant $tenant,
Purchase $compra,
CheckoutService $checkoutService,
): PurchaseResource {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->updateCustomerData($compra, $request->validated()),
);
}
public function updateItemQuantity(
UpdatePurchaseItemQuantityRequest $request,
Tenant $tenant,
Purchase $compra,
PurchaseItem $item,
CheckoutService $checkoutService,
): PurchaseResource {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->updateItemQuantity(
$compra,
$item,
(int) $request->validated('quantity'),
),
);
}
public function prepareItemEditing(
Request $request,
Tenant $tenant,
Purchase $compra,
CheckoutService $checkoutService,
): PurchaseResource {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->prepareItemEditing($compra),
);
}
public function paymentIntent(PaymentIntentRequest $request, Tenant $tenant, Purchase $compra): JsonResponse
@@ -68,6 +114,9 @@ class PurchaseController extends Controller
$purchaseUpdate = [
'payment_method' => $method,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'expires_at' => now()->addMinutes(
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30))
),
'total' => $totalAmount,
];
@@ -75,7 +124,22 @@ class PurchaseController extends Controller
$purchaseUpdate['transfer_payer_dni'] = preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'));
}
$compra->update($purchaseUpdate);
$updated = Purchase::query()
->whereKey($compra->getKey())
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
->where(function ($query): void {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->update($purchaseUpdate);
if ($updated === 0) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_available_for_payment'),
]);
}
$compra->refresh();
if ($method === 'transfer') {
$telepagosService = new TelepagosIntegrationService;
@@ -95,8 +159,14 @@ class PurchaseController extends Controller
],
]);
} catch (\Exception $e) {
Log::error('Unable to retrieve TelePagos account information.', [
'purchase_id' => $compra->id,
'exception' => $e,
]);
return response()->json([
'message' => 'Error getting account info: '.$e->getMessage(),
'code' => 'integration.account_info_failed',
'message' => __('api.integration.account_info_failed'),
], 500);
}
}
@@ -118,8 +188,14 @@ class PurchaseController extends Controller
'qr_code' => $qrResponse['qr_code'] ?? '',
]);
} catch (\Exception $e) {
Log::error('Unable to generate TelePagos QR code.', [
'purchase_id' => $compra->id,
'exception' => $e,
]);
return response()->json([
'message' => 'Error generating QR: '.$e->getMessage(),
'code' => 'integration.qr_generation_failed',
'message' => __('api.integration.qr_generation_failed'),
], 500);
}
@@ -133,7 +209,8 @@ class PurchaseController extends Controller
}
return response()->json([
'message' => 'Invalid payment method.',
'code' => 'integration.invalid_payment_method',
'message' => __('api.integration.invalid_payment_method'),
], 400);
}
@@ -146,25 +223,34 @@ class PurchaseController extends Controller
);
}
public function submitForReview(
Request $request,
Tenant $tenant,
Purchase $compra,
CheckoutService $checkoutService,
): PurchaseResource {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->submitForReview($compra),
);
}
public function cancel(Request $request, Tenant $tenant, Purchase $compra, CheckoutService $checkoutService): PurchaseResource
{
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->cancelPurchase($compra)
);
}
protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase
{
if ($purchase->tenant_codigo !== $tenant->codigo || $purchase->user_id !== $userId) {
throw new NotFoundHttpException('Purchase not found for tenant.');
throw new NotFoundHttpException(__('api.errors.not_found'));
}
return $purchase;
}
/**
* @return list<string>
*/
protected function loadCartCatalogEntries(Collection $items): void
{
$items->load([
'catalogItem.attachments',
'variant.attachments',
'variant.catalogItem.attachments',
'variant.definitions.itemAttribute.attribute',
]);
}
}

View File

@@ -6,6 +6,7 @@ use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -20,6 +21,7 @@ use Illuminate\Support\Facades\DB;
'user_id',
'status',
'payment_method',
'expires_at',
'total',
'dni',
'transfer_payer_dni',
@@ -35,12 +37,16 @@ class Purchase extends Model
public const STATUS_PENDING_PAYMENT = 'pending_payment';
public const STATUS_IN_REVIEW = 'in_review';
public const STATUS_PAID = 'paid';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_REJECTED = 'rejected';
public const STATUS_EXPIRED = 'expired';
protected $table = 'compras';
protected function casts(): array
@@ -48,6 +54,7 @@ class Purchase extends Model
return [
'cart_id' => 'integer',
'user_id' => 'integer',
'expires_at' => 'datetime',
'total' => 'decimal:2',
];
}
@@ -84,6 +91,14 @@ class Purchase extends Model
return $this->hasMany(PurchaseItem::class, 'compra_id');
}
/**
* @return HasMany<Ticket, $this>
*/
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class, 'source_purchase_id');
}
/**
* @return HasOne<TelepagosQr, $this>
*/
@@ -111,25 +126,13 @@ class Purchase extends Model
public function calculateCurrentTotalAmount(): float
{
if ($this->relationLoaded('items') && $this->getRelation('items')->isNotEmpty()) {
if ($this->relationLoaded('items')) {
return (float) $this->getRelation('items')->sum('total');
}
if ($this->items()->exists()) {
return (float) $this->items()->sum('total');
}
$cart = $this->relationLoaded('cart')
? $this->getRelation('cart')
: $this->cart()->with(['items.catalogItem', 'items.variant'])->first();
if (! $cart) {
return 0.0;
}
return $cart->getTotalAmount();
}
public function markAsPendingPayment(): void
{
$this->update([

View File

@@ -23,11 +23,18 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
'discount_total',
'tax_total',
'total',
'reservation_status',
])]
class PurchaseItem extends Model
{
use HasFactory;
public const RESERVATION_ACTIVE = 'active';
public const RESERVATION_COMMITTED = 'committed';
public const RESERVATION_RELEASED = 'released';
protected $table = 'compra_items';
protected function casts(): array

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Domains\Purchase\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StartCheckoutRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'cart_id' => [
'required_without:direct_item',
Rule::prohibitedIf(fn (): bool => $this->has('direct_item')),
'integer',
'exists:carritos,id',
],
'direct_item' => [
'required_without:cart_id',
Rule::prohibitedIf(fn (): bool => $this->has('cart_id')),
'array',
],
'direct_item.catalog_item_id' => [
'required_with:direct_item',
'integer',
],
'direct_item.variant_id' => [
'nullable',
'integer',
],
'direct_item.cantidad' => [
'required_with:direct_item',
'integer',
'min:1',
],
'dni' => ['prohibited'],
'telefono' => ['prohibited'],
'nombre_apellido' => ['prohibited'],
'email' => ['prohibited'],
];
}
}

View File

@@ -4,7 +4,7 @@ namespace App\Domains\Purchase\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePurchaseRequest extends FormRequest
class UpdatePurchaseCustomerRequest extends FormRequest
{
public function authorize(): bool
{
@@ -17,7 +17,6 @@ class StorePurchaseRequest extends FormRequest
public function rules(): array
{
return [
'cart_id' => ['required', 'integer', 'exists:carritos,id'],
'dni' => ['required', 'string'],
'telefono' => ['required', 'string'],
'nombre_apellido' => ['required', 'string'],

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Purchase\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdatePurchaseItemQuantityRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'quantity' => ['required', 'integer', 'min:1', 'max:100'],
];
}
}

View File

@@ -2,12 +2,10 @@
namespace App\Domains\Purchase\Resources;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
/**
* @mixin Purchase
@@ -19,18 +17,23 @@ class PurchaseResource extends JsonResource
*/
public function toArray(Request $request): array
{
[$items, $itemsSource] = $this->resolveItems();
$items = $this->resource->relationLoaded('items')
? $this->resource->getRelation('items')
: collect();
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
? (int) $this->resource->getAttribute('tickets_count')
: null;
$subtotal = $items->isNotEmpty()
? $items->reduce(
fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemSubtotal($item),
fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemSubtotal($item),
0.0,
)
: (float) ($this->total ?? 0);
$total = $items->isNotEmpty()
? $items->reduce(
fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemTotal($item),
fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemTotal($item),
0.0,
)
: (float) ($this->total ?? 0);
@@ -43,64 +46,31 @@ class PurchaseResource extends JsonResource
'created_at' => $this->created_at,
'status' => $this->status,
'payment_method' => $this->payment_method,
'expires_at' => $this->expires_at,
'dni' => $this->dni,
'transfer_payer_dni' => $this->transfer_payer_dni,
'telefono' => $this->telefono,
'nombre_apellido' => $this->nombre_apellido,
'email' => $this->email,
'items_source' => $itemsSource,
'items_source' => $items->isNotEmpty() ? 'purchase' : null,
'items' => PurchaseItemResource::collection($items),
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
'subtotal' => $this->formatMoney($subtotal),
'total' => $this->formatMoney($total),
];
}
/**
* @return array{0: Collection<int, PurchaseItem|CartItem>, 1: string|null}
*/
protected function resolveItems(): array
protected function resolveItemSubtotal(PurchaseItem $item): float
{
if (! $this->resource->relationLoaded('items')) {
return [collect(), null];
}
$purchaseItems = $this->resource->getRelation('items');
if ($purchaseItems->isNotEmpty()) {
return [$purchaseItems, 'purchase'];
}
if (! $this->resource->relationLoaded('cart')) {
return [collect(), null];
}
$cart = $this->resource->getRelation('cart');
if ($cart === null || ! $cart->relationLoaded('items')) {
return [collect(), null];
}
return [$cart->getRelation('items'), 'cart'];
}
protected function resolveItemSubtotal(PurchaseItem|CartItem $item): float
{
if ($item instanceof PurchaseItem) {
return (float) $item->precio_unitario * $item->cantidad;
}
return (float) ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad;
}
protected function resolveItemTotal(PurchaseItem|CartItem $item): float
protected function resolveItemTotal(PurchaseItem $item): float
{
if ($item instanceof PurchaseItem) {
return (float) ($item->total ?? 0);
}
return $this->resolveItemSubtotal($item);
}
protected function formatMoney(float|int|string|null $amount): string
{
return number_format((float) ($amount ?? 0), 2, '.', '');

View File

@@ -3,77 +3,54 @@
namespace App\Domains\Purchase\Services;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
class CheckoutService
{
public function __construct(
private readonly AttachmentService $attachmentService,
private readonly CatalogInventoryService $catalogInventoryService,
) {}
public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase
{
$cartId = (int) $purchaseData['cart_id'];
unset($purchaseData['cart_id']);
return DB::transaction(function () use ($tenant, $userId, $purchaseData): Purchase {
$directItem = $purchaseData['direct_item'] ?? null;
$cartId = isset($purchaseData['cart_id']) ? (int) $purchaseData['cart_id'] : null;
unset($purchaseData['direct_item'], $purchaseData['cart_id']);
return DB::transaction(function () use ($tenant, $userId, $purchaseData, $cartId): Purchase {
$cart = $this->resolveCheckoutCart($tenant, $userId, $cartId);
$cartItems = $cart->items()->lockForUpdate()->get();
if (is_array($directItem)) {
return $this->startDirectCheckout(
$tenant,
$userId,
$purchaseData,
$directItem,
);
}
if ($cartItems->isEmpty()) {
if ($cartId === null) {
throw ValidationException::withMessages([
'cart_id' => 'The selected cart does not contain items.',
'cart_id' => __('api.purchase.source_required'),
]);
}
$this->loadCartItems($cartItems);
$cart->setRelation('items', $cartItems);
$totalAmount = $cart->getTotalAmount();
/** @var Purchase|null $purchase */
$purchase = Purchase::query()
->where('cart_id', $cart->getKey())
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $userId)
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
->latest('id')
->first();
if ($purchase === null) {
/** @var Purchase $purchase */
$purchase = Purchase::query()->create([
...$purchaseData,
'cart_id' => $cart->getKey(),
'tenant_codigo' => $tenant->codigo,
'user_id' => $userId,
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'total' => $totalAmount,
]);
} else {
$purchase->fill([
...$purchaseData,
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'total' => $totalAmount,
]);
$purchase->save();
}
return $this->loadPurchase($purchase);
return $this->startCartCheckout(
$tenant,
$userId,
$purchaseData,
$cartId,
);
});
}
@@ -87,11 +64,17 @@ class CheckoutService
if ($purchase->payment_method === null) {
throw ValidationException::withMessages([
'payment_method' => 'The purchase payment method must be selected before finalizing.',
'payment_method' => __('api.purchase.payment_method_required'),
]);
}
if (in_array($purchase->status, [Purchase::STATUS_PAID, Purchase::STATUS_CANCELLED, Purchase::STATUS_REJECTED], true)) {
if (in_array($purchase->status, [
Purchase::STATUS_PAID,
Purchase::STATUS_IN_REVIEW,
Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED,
], true)) {
return $this->loadPurchase($purchase);
}
@@ -104,76 +87,564 @@ class CheckoutService
});
}
public function confirmPurchase(Purchase $purchase): void
public function submitForReview(Purchase $purchase): Purchase
{
$snapshotPaths = [];
try {
DB::transaction(function () use ($purchase, &$snapshotPaths): void {
return DB::transaction(function () use ($purchase): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
if ($purchase->items()->exists()) {
return;
if (in_array($purchase->status, [
Purchase::STATUS_IN_REVIEW,
Purchase::STATUS_PAID,
], true)) {
return $this->loadPurchase($purchase);
}
/** @var Cart|null $cart */
$cart = $purchase->cart()->lockForUpdate()->first();
if ($cart === null) {
if (
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'cart_id' => 'The purchase cart is no longer available.',
'purchase' => __('api.purchase.not_available_for_review'),
]);
}
$purchase->update([
'status' => Purchase::STATUS_IN_REVIEW,
'expires_at' => null,
]);
return $this->loadPurchase($purchase);
});
}
/**
* @param array<string, string> $customerData
*/
public function updateCustomerData(Purchase $purchase, array $customerData): Purchase
{
return DB::transaction(function () use ($purchase, $customerData): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
if (
! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_editable'),
]);
}
$purchase->update($customerData);
return $this->loadPurchase($purchase);
});
}
public function updateItemQuantity(
Purchase $purchase,
PurchaseItem $purchaseItem,
int $quantity,
): Purchase {
return DB::transaction(function () use ($purchase, $purchaseItem, $quantity): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
if (
$purchase->status !== Purchase::STATUS_CREATED
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_editable'),
]);
}
/** @var PurchaseItem|null $purchaseItem */
$purchaseItem = $purchase->items()
->whereKey($purchaseItem->getKey())
->lockForUpdate()
->first();
if ($purchaseItem === null) {
throw new NotFoundHttpException('Purchase item not found.');
}
if ($purchaseItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) {
throw ValidationException::withMessages([
'item' => __('api.purchase.item_not_editable'),
]);
}
$currentQuantity = (int) $purchaseItem->cantidad;
$difference = $quantity - $currentQuantity;
if ($difference !== 0) {
$selection = $this->resolvePurchaseItemSelection($purchase->tenant, $purchaseItem);
try {
if ($difference > 0) {
$this->catalogInventoryService->reserve($selection, $difference);
} else {
$this->catalogInventoryService->release($selection, abs($difference));
}
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'quantity' => __('api.purchase.insufficient_stock'),
]);
}
$purchaseItem->update([
'cantidad' => $quantity,
'total' => (float) $purchaseItem->precio_unitario * $quantity,
]);
$this->syncSourceCartItemQuantity($purchase, $purchaseItem, $quantity);
}
$purchase->update([
'total' => $purchase->calculateCurrentTotalAmount(),
]);
return $this->loadPurchase($purchase);
});
}
public function prepareItemEditing(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
if (
! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_editable'),
]);
}
$purchase->telepagosQr()->delete();
$purchase->update([
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'transfer_payer_dni' => null,
'expires_at' => now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
),
]);
return $this->loadPurchase($purchase);
});
}
public function confirmPurchase(Purchase $purchase): void
{
DB::transaction(function () use ($purchase): void {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
if ($purchase->status === Purchase::STATUS_PAID) {
return;
}
if (in_array($purchase->status, [
Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED,
], true)) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.cannot_confirm'),
]);
}
$items = $purchase->items()
->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE)
->lockForUpdate()
->get();
foreach ($items as $item) {
$selection = $this->resolvePurchaseItemSelection($purchase->tenant, $item);
try {
$this->catalogInventoryService->commit($selection, (int) $item->cantidad);
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$item->update([
'reservation_status' => PurchaseItem::RESERVATION_COMMITTED,
]);
}
$this->finalizeSourceCart($purchase);
});
}
public function cancelPurchase(Purchase $purchase): Purchase
{
return $this->releasePurchase($purchase, Purchase::STATUS_CANCELLED);
}
public function expirePurchase(Purchase $purchase): Purchase
{
return $this->releasePurchase($purchase, Purchase::STATUS_EXPIRED);
}
public function expireOverduePurchases(): int
{
$expiredCount = 0;
Purchase::query()
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->orderBy('id')
->eachById(function (Purchase $purchase) use (&$expiredCount): void {
$purchase = $this->expirePurchase($purchase);
if ($purchase->status === Purchase::STATUS_EXPIRED) {
$expiredCount++;
}
});
return $expiredCount;
}
private function releasePurchase(Purchase $purchase, string $targetStatus): Purchase
{
return DB::transaction(function () use ($purchase, $targetStatus): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
if ($purchase->status === Purchase::STATUS_PAID) {
if ($targetStatus === Purchase::STATUS_EXPIRED) {
return $this->loadPurchase($purchase);
}
throw ValidationException::withMessages([
'purchase' => __('api.purchase.paid_cannot_cancel'),
]);
}
if (in_array($purchase->status, [
Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED,
], true)) {
return $this->loadPurchase($purchase);
}
if (
$targetStatus === Purchase::STATUS_EXPIRED
&& ($purchase->expires_at === null || $purchase->expires_at->isFuture())
) {
return $this->loadPurchase($purchase);
}
$items = $purchase->items()
->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE)
->lockForUpdate()
->get();
$reservationReturnedToCart = $this->restoreSourceCart($purchase);
foreach ($items as $item) {
if (! $reservationReturnedToCart) {
$selection = $this->resolvePurchaseItemSelection($purchase->tenant, $item);
try {
$this->catalogInventoryService->release($selection, (int) $item->cantidad);
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
}
$item->update([
'reservation_status' => PurchaseItem::RESERVATION_RELEASED,
]);
}
$purchase->update([
'status' => $targetStatus,
]);
return $this->loadPurchase($purchase);
});
}
/**
* @param array<string, mixed> $purchaseData
* @param array<string, mixed> $directItem
*/
private function startDirectCheckout(
Tenant $tenant,
int $userId,
array $purchaseData,
array $directItem,
): Purchase {
$catalogItemId = (int) $directItem['catalog_item_id'];
$variantId = isset($directItem['variant_id']) ? (int) $directItem['variant_id'] : null;
$quantity = (int) $directItem['cantidad'];
$selection = $this->resolveSelection($tenant, $catalogItemId, $variantId);
$availableQuantity = $this->catalogInventoryService->availableQuantity($selection);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
'direct_item.cantidad' => __('api.purchase.direct_item_max_stock', ['max' => $availableQuantity]),
]);
}
try {
$this->catalogInventoryService->reserve($selection, $quantity);
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'direct_item.cantidad' => __('api.purchase.insufficient_stock'),
]);
}
$purchase = $this->createPurchase(
$tenant,
$userId,
$purchaseData,
$selection->getPrice() * $quantity,
null,
);
$cartItem = $this->makeDirectCartItem($selection, $catalogItemId, $variantId, $quantity);
$purchase->items()->createMany(
$this->buildPurchaseItemsPayload(collect([$cartItem])),
);
return $this->loadPurchase($purchase);
}
/**
* @param array<string, mixed> $purchaseData
*/
private function startCartCheckout(
Tenant $tenant,
int $userId,
array $purchaseData,
int $cartId,
): Purchase {
$cart = $this->resolveCheckoutCart($tenant, $userId, $cartId);
$cartItems = $cart->items()->lockForUpdate()->get();
if ($cartItems->isEmpty()) {
throw ValidationException::withMessages([
'cart_id' => 'The purchase cart does not contain items.',
'cart_id' => __('api.purchase.empty_cart'),
]);
}
$this->loadCartItems($cartItems);
$this->verifyTenantItems($purchase->tenant, $cartItems);
$purchaseItemsPayload = $this->buildPurchaseItemsPayload($purchase, $cartItems, $snapshotPaths);
$this->verifyTenantItems($tenant, $cartItems);
$cart->setRelation('items', $cartItems);
$purchase->items()->createMany($purchaseItemsPayload);
$this->completeCartConversion($cart, $cartItems);
});
} catch (Throwable $throwable) {
foreach ($snapshotPaths as $snapshotPath) {
Storage::disk('s3')->delete($snapshotPath);
$purchase = $this->createPurchase(
$tenant,
$userId,
$purchaseData,
$cart->getTotalAmount(),
$cart->getKey(),
);
$purchase->items()->createMany(
$this->buildPurchaseItemsPayload($cartItems),
);
// PurchaseItem owns the reservation during checkout. The source cart is
// kept with its owner so it can be restored if the purchase is cancelled
// or expires. Only active carts participate in the identity constraint.
$cart->update([
'status' => 'checkout',
'guest_token' => null,
]);
return $this->loadPurchase($purchase);
}
throw $throwable;
private function restoreSourceCart(Purchase $purchase): bool
{
if ($purchase->cart_id === null) {
return false;
}
/** @var Cart|null $sourceCart */
$sourceCart = Cart::withTrashed()
->whereKey($purchase->cart_id)
->lockForUpdate()
->first();
if ($sourceCart === null) {
return false;
}
/** @var Cart|null $activeCart */
$activeCart = Cart::query()
->where('tenant_codigo', $purchase->tenant_codigo)
->where('user_id', $purchase->user_id)
->where('status', 'active')
->where('id', '!=', $sourceCart->getKey())
->lockForUpdate()
->first();
if ($activeCart !== null) {
$sourceItems = $sourceCart->items()->lockForUpdate()->get();
foreach ($sourceItems as $sourceItem) {
/** @var CartItem|null $activeItem */
$activeItem = $activeCart->items()
->where('catalog_item_id', $sourceItem->catalog_item_id)
->where('variant_id', $sourceItem->variant_id)
->lockForUpdate()
->first();
if ($activeItem === null) {
$activeCart->items()->create([
'catalog_item_id' => $sourceItem->catalog_item_id,
'variant_id' => $sourceItem->variant_id,
'cantidad' => $sourceItem->cantidad,
]);
} else {
$activeItem->increment('cantidad', (int) $sourceItem->cantidad);
}
}
$sourceCart->update([
'status' => 'converted',
'guest_token' => null,
]);
if (! $sourceCart->trashed()) {
$sourceCart->delete();
}
return true;
}
if ($sourceCart->trashed()) {
$sourceCart->restore();
}
$sourceCart->update([
'status' => 'active',
'user_id' => $purchase->user_id,
'guest_token' => null,
]);
return true;
}
private function syncSourceCartItemQuantity(
Purchase $purchase,
PurchaseItem $purchaseItem,
int $quantity,
): void {
if ($purchase->cart_id === null) {
return;
}
$sourceCart = Cart::withTrashed()
->whereKey($purchase->cart_id)
->lockForUpdate()
->first();
if ($sourceCart === null) {
return;
}
$sourceCart->items()
->where('catalog_item_id', $purchaseItem->source_catalog_item_id)
->where('variant_id', $purchaseItem->source_variant_id)
->update([
'cantidad' => $quantity,
]);
}
private function finalizeSourceCart(Purchase $purchase): void
{
if ($purchase->cart_id === null) {
return;
}
/** @var Cart|null $sourceCart */
$sourceCart = Cart::withTrashed()
->whereKey($purchase->cart_id)
->lockForUpdate()
->first();
if ($sourceCart === null || $sourceCart->trashed()) {
return;
}
$sourceCart->update([
'status' => 'converted',
'guest_token' => null,
]);
$sourceCart->delete();
}
/**
* @param array<string, mixed> $purchaseData
*/
private function createPurchase(
Tenant $tenant,
int $userId,
array $purchaseData,
float $total,
?int $cartId,
): Purchase {
return Purchase::query()->create([
...$purchaseData,
'cart_id' => $cartId,
'tenant_codigo' => $tenant->codigo,
'user_id' => $userId,
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'expires_at' => now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
),
'total' => $total,
]);
}
protected function verifyTenantItems(Tenant $tenant, Collection $cartItems): void
{
foreach ($cartItems as $item) {
$selectedItem = $item->selectedItem();
if ($selectedItem === null) {
if ($item->selectedItem() === null) {
throw ValidationException::withMessages([
'cart_id' => 'One or more catalog items could not be loaded.',
'cart_id' => __('api.purchase.catalog_item_missing'),
]);
}
if ($item->catalogItem?->tenant_code !== $tenant->codigo) {
throw ValidationException::withMessages([
'cart_id' => 'One or more catalog items do not belong to the tenant.',
'cart_id' => __('api.purchase.catalog_item_wrong_tenant'),
]);
}
}
}
/**
* @param Collection<int, CartItem> $cartItems
* @return Collection<int, CartItem>
*/
protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart
{
/** @var Cart|null $cart */
@@ -187,7 +658,7 @@ class CheckoutService
if ($cart->status !== 'active') {
throw ValidationException::withMessages([
'cart_id' => 'The selected cart is no longer active.',
'cart_id' => __('api.purchase.inactive_cart'),
]);
}
@@ -198,21 +669,14 @@ class CheckoutService
* @param Collection<int, CartItem> $cartItems
* @return array<int, array<string, mixed>>
*/
protected function buildPurchaseItemsPayload(
Purchase $purchase,
Collection $cartItems,
array &$snapshotPaths = [],
): array {
protected function buildPurchaseItemsPayload(Collection $cartItems): array
{
return $cartItems
->map(function (CartItem $item) use ($purchase, &$snapshotPaths): array {
->map(function (CartItem $item): array {
$selectedItem = $item->selectedItem();
$quantity = (int) $item['cantidad'];
$quantity = (int) $item->cantidad;
$unitPrice = $selectedItem?->getPrice() ?? 0;
$imageAttachment = $this->snapshotFirstImage($purchase, $item);
if ($imageAttachment !== null) {
$snapshotPaths[] = $imageAttachment->path;
}
$imageAttachment = $this->firstImageAttachment($item);
return [
'source_catalog_item_id' => $item->catalog_item_id,
@@ -230,37 +694,117 @@ class CheckoutService
'discount_total' => null,
'tax_total' => null,
'total' => $unitPrice * $quantity,
'reservation_status' => PurchaseItem::RESERVATION_ACTIVE,
];
})
->all();
}
/**
* @param Collection<int, CartItem> $cartItems
*/
protected function completeCartConversion(Cart $cart, Collection $cartItems): void
{
foreach ($cartItems as $item) {
$selectedItem = $item->selectedItem();
$quantity = (int) $item->cantidad;
private function resolveSelection(
Tenant $tenant,
int $catalogItemId,
?int $variantId,
): CatalogItem|Variant {
/** @var CatalogItem|null $catalogItem */
$catalogItem = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $tenant->codigo)
->lockForUpdate()
->first();
try {
$this->catalogInventoryService->commit(
$selectedItem,
$quantity,
);
} catch (\InvalidArgumentException $exception) {
if ($catalogItem === null) {
throw new NotFoundHttpException('Catalog item not found for tenant.');
}
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
'cart_id' => 'The selected cart has inconsistent stock state.',
'direct_item.variant_id' => __('api.cart.bundle_variant_forbidden'),
]);
}
if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([
'direct_item.catalog_item_id' => __('api.cart.empty_bundle'),
]);
}
$cart->status = 'converted';
$cart->user_id = null;
$cart->guest_token = null;
$cart->save();
$cart->delete();
return $catalogItem;
}
if ($variantId === null) {
if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([
'direct_item.variant_id' => __('api.cart.variant_required'),
]);
}
$catalogItem->setRelation(
'inventory',
Inventory::query()->whereKey($catalogItem->inventory_id)->lockForUpdate()->firstOrFail(),
);
return $catalogItem;
}
/** @var Variant|null $variant */
$variant = Variant::query()
->whereKey($variantId)
->where('catalog_item_id', $catalogItem->id)
->lockForUpdate()
->first();
if ($variant === null) {
throw new NotFoundHttpException('Variant not found for catalog item.');
}
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation(
'inventory',
Inventory::query()->whereKey($variant->inventory_id)->lockForUpdate()->firstOrFail(),
);
return $variant;
}
private function resolvePurchaseItemSelection(Tenant $tenant, PurchaseItem $item): CatalogItem|Variant
{
return $this->resolveSelection(
$tenant,
(int) $item->source_catalog_item_id,
$item->source_variant_id === null ? null : (int) $item->source_variant_id,
);
}
private function makeDirectCartItem(
CatalogItem|Variant $selection,
int $catalogItemId,
?int $variantId,
int $quantity,
): CartItem {
$catalogItem = $selection instanceof Variant
? $selection->catalogItem
: $selection;
$catalogItem->loadMissing(['inventory', 'attachments']);
if ($selection instanceof Variant) {
$selection->loadMissing([
'inventory',
'attachments',
'catalogItem',
'definitions.itemAttribute.attribute',
]);
}
$item = new CartItem([
'catalog_item_id' => $catalogItemId,
'variant_id' => $variantId,
'cantidad' => $quantity,
]);
$item->setRelation('catalogItem', $catalogItem);
$item->setRelation('variant', $selection instanceof Variant ? $selection : null);
return $item;
}
/** @param Collection<int, CartItem> $cartItems */
@@ -283,19 +827,10 @@ class CheckoutService
]);
}
private function snapshotFirstImage(Purchase $purchase, CartItem $item): ?Attachment
private function firstImageAttachment(CartItem $item): ?Attachment
{
$source = $item->variant?->attachments->first()
return $item->variant?->attachments->first()
?? $item->catalogItem?->attachments->first();
if ($source === null) {
return null;
}
return $this->attachmentService->copy(
$source,
"purchase/{$purchase->id}",
);
}
/** @return array<int, array{name: string, value: mixed}> */

View File

@@ -3,8 +3,15 @@
use App\Domains\Purchase\Controllers\PurchaseController;
use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')->middleware(['auth:sanctum', \App\Domains\Cart\Middleware\MergeGuestCartMiddleware::class])->group(function (): void {
Route::apiResource('compras', PurchaseController::class)->only(['index', 'store', 'show']);
Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(function (): void {
Route::get('compras', [PurchaseController::class, 'index']);
Route::post('compras/start-checkout', [PurchaseController::class, 'startCheckout']);
Route::get('compras/{compra}', [PurchaseController::class, 'show']);
Route::post('compras/{compra}/edit-items', [PurchaseController::class, 'prepareItemEditing']);
Route::patch('compras/{compra}/items/{item}', [PurchaseController::class, 'updateItemQuantity']);
Route::patch('compras/{compra}/customer-data', [PurchaseController::class, 'updateCustomerData']);
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']);
Route::post('compras/{compra}/review', [PurchaseController::class, 'submitForReview']);
Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']);
});

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Tenant\Controllers\AdminApp;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Tenant\Requests\BootstrapAdminAppRequest;
use App\Domains\Tenant\Resources\AdminApp\BootstrapAdminAppResource;
use App\Http\Controllers\Controller;
class BootstrapAdminAppController extends Controller
{
public function __invoke(
BootstrapAdminAppRequest $request
): BootstrapAdminAppResource {
/** @var string $domain */
$domain = $request->validated('dominio');
return BootstrapAdminAppResource::make(
WebsiteType::query()
->with(['siteLogo', 'footerLogo'])
->where('dominio', $domain)
->firstOrFail()
);
}
}

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Requests\StoreTenantRequest;
use App\Domains\Tenant\Requests\UpdateTenantRequest;
use App\Domains\Tenant\Resources\TenantResource;
use App\Domains\Tenant\Services\TenantInformationService;
use App\Domains\Tenant\Services\TenantService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
@@ -13,32 +14,45 @@ use Illuminate\Http\Response;
class TenantController extends Controller
{
public function __construct(protected TenantService $tenantService)
{
}
public function __construct(
protected TenantService $tenantService,
protected TenantInformationService $tenantInformationService,
) {}
public function index(): JsonResponse
{
return TenantResource::collection(Tenant::query()->with(['headerLogo', 'footerLogo'])->latest()->paginateFromRequest())->response();
$tenants = Tenant::query()
->latest()
->paginateFromRequest();
$this->tenantInformationService->loadMany($tenants->getCollection());
return TenantResource::collection($tenants)->response();
}
public function store(StoreTenantRequest $request): JsonResponse
{
$tenant = $this->tenantService->create($request->validated());
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']))->response()->setStatusCode(201);
return TenantResource::make(
$this->tenantInformationService->load($tenant)
)->response()->setStatusCode(201);
}
public function show(Tenant $tenant): TenantResource
{
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']));
return TenantResource::make(
$this->tenantInformationService->load($tenant)
);
}
public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource
{
$tenant = $this->tenantService->update($tenant, $request->validated());
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']));
return TenantResource::make(
$this->tenantInformationService->load($tenant)
);
}
public function destroy(Tenant $tenant): Response

View File

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

View File

@@ -3,8 +3,12 @@
namespace App\Domains\Tenant\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Models\TenantMenu;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -24,13 +28,21 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'footer_bg_color',
'header_logo_id',
'footer_logo_id',
'hero_config',
'event_config',
'website_type_code',
'search_product_layout',
'search_group_layout',
'search_items_per_page',
])]
class Tenant extends Model
{
use HasFactory;
protected $attributes = [
'search_product_layout' => ProductLayout::ColumnWithImage->value,
'search_group_layout' => GroupLayout::Paginated->value,
'search_items_per_page' => 12,
];
public function getRouteKeyName(): string
{
return 'codigo';
@@ -44,8 +56,9 @@ class Tenant extends Model
protected function casts(): array
{
return [
'hero_config' => 'array',
'event_config' => 'array',
'search_product_layout' => ProductLayout::class,
'search_group_layout' => GroupLayout::class,
'search_items_per_page' => 'integer',
];
}
@@ -66,11 +79,11 @@ class Tenant extends Model
}
/**
* @return BelongsTo<Attachment, $this>
* @return BelongsTo<WebsiteType, $this>
*/
public function heroBgImage(): BelongsTo
public function websiteType(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'hero_bg_image_id');
return $this->belongsTo(WebsiteType::class, 'website_type_code', 'codigo');
}
public function catalogItems(): HasMany
@@ -78,15 +91,52 @@ class Tenant extends Model
return $this->hasMany(CatalogItem::class, 'tenant_code', 'codigo');
}
/**
* @return HasMany<Category, $this>
*/
public function categories(): HasMany
{
return $this->hasMany(Category::class, 'tenant_code', 'codigo');
}
/**
* @return BelongsToMany<SocialMedia, $this>
*/
public function socialMedia(): BelongsToMany
{
return $this->belongsToMany(
SocialMedia::class,
'tenant_social_media',
'tenant_code',
'social_media_code',
'codigo',
'code'
)
->withPivot(['url', 'orden'])
->withTimestamps()
->orderByPivot('orden');
}
/**
* @return HasMany<WebsiteExtra, $this>
*/
public function websiteExtras(): HasMany
{
return $this->hasMany(WebsiteExtra::class, 'website_code', 'codigo');
}
public function menues(): BelongsToMany
{
return $this->belongsToMany(
Menu::class,
'tenant_menues',
'tenant_codigo',
'tenants_menues',
'tenant_code',
'menu_code',
'codigo',
'code'
)->withTimestamps();
)
->using(TenantMenu::class)
->withPivot('static_content')
->withTimestamps();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
<?php
namespace App\Domains\Tenant\Requests;
class BootstrapAdminAppRequest extends BootstrapTenantRequest {}

View File

@@ -2,7 +2,10 @@
namespace App\Domains\Tenant\Requests;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Tenant\Services\WebsiteExtraService;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
@@ -35,10 +38,9 @@ class StoreTenantRequest extends FormRequest
*/
public function rules(): array
{
$logoRule = ['required', new ImageOrBase64Rule()];
$logoRule = ['required', new ImageOrBase64Rule];
return [
return array_merge([
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
'nombre' => ['required', 'string', 'max:255'],
'dominio' => [
@@ -61,17 +63,24 @@ class StoreTenantRequest extends FormRequest
'footer_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_logo' => $logoRule,
'footer_logo' => $logoRule,
'hero_bg_image' => ['nullable', new ImageOrBase64Rule()],
'hero_config' => ['nullable', 'array'],
'hero_config.title_html' => ['nullable', 'string'],
'hero_config.description_html' => ['nullable', 'string'],
'hero_config.button_text' => ['nullable', 'string'],
'hero_config.button_href' => ['nullable', 'string'],
'event_config' => ['nullable', 'array'],
'event_config.title' => ['nullable', 'string'],
'event_config.location' => ['nullable', 'string'],
'event_config.dates' => ['nullable', 'array'],
'event_config.dates.*' => ['required', 'string'],
];
'social_media' => ['sometimes', 'array'],
'social_media.*.code' => [
'required',
'string',
'distinct',
Rule::exists('social_media', 'code'),
],
'social_media.*.url' => ['required', 'url', 'max:2048'],
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
'website_type_code' => [
'required_with:extras',
'sometimes',
'string',
Rule::exists('website_type', 'codigo'),
],
], app(WebsiteExtraService::class)->requestRules($this->input('website_type_code')));
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Domains\Tenant\Requests;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
@@ -41,8 +43,7 @@ class UpdateTenantRequest extends FormRequest
/** @var Tenant|null $tenant */
$tenant = $this->route('tenant');
$logoRule = ['nullable', new ImageOrBase64Rule()];
$logoRule = ['nullable', new ImageOrBase64Rule];
return [
'codigo' => [
@@ -72,17 +73,18 @@ class UpdateTenantRequest extends FormRequest
'footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_logo' => $logoRule,
'footer_logo' => $logoRule,
'hero_bg_image' => ['nullable', new ImageOrBase64Rule()],
'hero_config' => ['nullable', 'array'],
'hero_config.title_html' => ['nullable', 'string'],
'hero_config.description_html' => ['nullable', 'string'],
'hero_config.button_text' => ['nullable', 'string'],
'hero_config.button_href' => ['nullable', 'string'],
'event_config' => ['nullable', 'array'],
'event_config.title' => ['nullable', 'string'],
'event_config.location' => ['nullable', 'string'],
'event_config.dates' => ['nullable', 'array'],
'event_config.dates.*' => ['required', 'string'],
'social_media' => ['sometimes', 'array'],
'social_media.*.code' => [
'required',
'string',
'distinct',
Rule::exists('social_media', 'code'),
],
'social_media.*.url' => ['required', 'url', 'max:2048'],
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Tenant\Resources\AdminApp;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin WebsiteType
*/
class BootstrapAdminAppResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'website_type_code' => $this->codigo,
'primary_color' => $this->primary_color,
'secondary_color' => $this->secondary_color,
'danger_color' => $this->danger_color,
'success_color' => $this->success_color,
'warning_color' => $this->warning_color,
'body_color' => $this->body_color,
'darker_body_color' => $this->darker_body_color,
'surface_color' => $this->surface_color,
'background_color' => $this->background_color,
'border_color' => $this->border_color,
'login_header_footer_color' => $this->login_header_footer_color,
'site_logo' => $this->siteLogo?->getTemporaryUrl(1440),
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440),
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Domains\Tenant\Resources\AdminApp;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Tenant\Models\WebsiteExtra;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin WebsiteExtra
*/
class WebsiteExtraResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'codigo' => $this->websiteTypeExtra->codigo,
'nombre' => $this->websiteTypeExtra->nombre,
'descripcion' => $this->websiteTypeExtra->descripcion,
'is_required' => $this->websiteTypeExtra->is_required,
'is_enabled' => $this->is_enabled,
'request_rules' => $this->websiteTypeExtra->config_schema['request_rules'] ?? [],
'config' => $this->formatConfig(
$this->resolvedConfig(),
fn (Attachment $attachment): string => $attachment->key
),
'resolved_config' => $this->formatConfig(
$this->resolvedConfig(),
fn (Attachment $attachment): string => $attachment->getTemporaryUrl(1440)
),
];
}
private function formatConfig(mixed $value, callable $formatAttachment): mixed
{
if ($value instanceof Attachment) {
return $formatAttachment($value);
}
if (! is_array($value)) {
return $value;
}
return array_map(
fn (mixed $item): mixed => $this->formatConfig($item, $formatAttachment),
$value
);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Domains\Tenant\Resources\AdminApp;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Tenant
*/
class WebsiteExtrasResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$websiteExtras = $this->websiteExtras->keyBy(
fn ($extra) => $extra->websiteTypeExtra->codigo
);
return [
'website_type' => $this->websiteType ? [
'codigo' => $this->websiteType->codigo,
'nombre' => $this->websiteType->nombre,
] : null,
'definitions' => $this->websiteType?->extras
->mapWithKeys(fn ($definition) => [
$definition->codigo => [
'codigo' => $definition->codigo,
'nombre' => $definition->nombre,
'descripcion' => $definition->descripcion,
'is_required' => $definition->is_required,
'is_enabled' => $websiteExtras
->get($definition->codigo)?->is_enabled,
'request_rules' => $definition->config_schema['request_rules'] ?? [],
],
]) ?? [],
'extras' => $websiteExtras->mapWithKeys(fn ($extra) => [
$extra->websiteTypeExtra->codigo => $this->formatConfig(
$extra->resolvedConfig(),
fn (Attachment $attachment): string => $attachment->key
),
]),
'resolved_extras' => $websiteExtras->mapWithKeys(fn ($extra) => [
$extra->websiteTypeExtra->codigo => $this->formatConfig(
$extra->resolvedConfig(),
fn (Attachment $attachment): string => $attachment->getTemporaryUrl(1440)
),
]),
];
}
private function formatConfig(mixed $value, callable $formatAttachment): mixed
{
if ($value instanceof Attachment) {
return $formatAttachment($value);
}
if (! is_array($value)) {
return $value;
}
return array_map(
fn (mixed $item): mixed => $this->formatConfig($item, $formatAttachment),
$value
);
}
}

View File

@@ -2,11 +2,16 @@
namespace App\Domains\Tenant\Resources;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\Category;
use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
/**
* @mixin \App\Domains\Tenant\Models\Tenant
* @mixin Tenant
*/
class TenantResource extends JsonResource
{
@@ -15,12 +20,6 @@ class TenantResource extends JsonResource
*/
public function toArray(Request $request): array
{
$heroConfig = $this->hero_config;
if (is_array($heroConfig)) {
$heroConfig['background_image'] = $this->heroBgImage?->getTemporaryUrl(1440);
unset($heroConfig['background_image_id']);
}
return [
'id' => $this->id,
'codigo' => $this->codigo,
@@ -32,12 +31,129 @@ class TenantResource extends JsonResource
'success_color' => $this->success_color,
'header_bg_color' => $this->header_bg_color,
'footer_bg_color' => $this->footer_bg_color,
'website_type_code' => $this->website_type_code,
'extras' => $this->whenLoaded(
'websiteExtras',
fn () => $this->websiteExtras
->filter(fn ($extra) => $extra->is_enabled)
->mapWithKeys(fn ($extra) => [
$extra->websiteTypeExtra->codigo => $this->formatExtraConfig(
$extra->resolvedConfig()
),
])
),
// 1 day
'header_logo' => $this->headerLogo?->getTemporaryUrl(1440),
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440),
'hero_config' => $heroConfig,
'event_config' => $this->event_config,
'menues' => $this->whenLoaded('menues'),
'search_product_layout' => $this->search_product_layout->value,
'search_group_layout' => $this->search_group_layout->value,
'search_items_per_page' => $this->search_items_per_page,
'social_media' => $this->whenLoaded(
'socialMedia',
fn () => $this->socialMedia
->map(fn ($socialMedia) => [
'code' => $socialMedia->code,
'icon' => $socialMedia->icon,
'name' => $socialMedia->name,
'url' => $socialMedia->pivot->url,
])
->values()
),
'menues' => $this->whenLoaded(
'menues',
fn () => $this->menuTree($this->menues)
),
'categories' => $this->whenLoaded(
'categories',
fn () => $this->categoryTree($this->categories)
),
];
}
private function formatExtraConfig(mixed $value): mixed
{
if ($value instanceof Attachment) {
return $value->getTemporaryUrl(1440);
}
if (! is_array($value)) {
return $value;
}
foreach ($value as $key => $item) {
$value[$key] = $this->formatExtraConfig($item);
}
return $value;
}
/**
* @param Collection<int, Category> $categories
* @return Collection<int, array<string, mixed>>
*/
private function categoryTree(Collection $categories): Collection
{
$categoryIds = $categories->pluck('id')->flip();
$childrenByParent = $categories
->filter(fn (Category $category) => $category->categoria_id !== null
&& $categoryIds->has($category->categoria_id))
->groupBy('categoria_id');
$formatCategory = function (Category $category) use (&$formatCategory, $childrenByParent): array {
return [
'id' => $category->id,
'nombre' => $category->nombre,
'subcategories' => $childrenByParent
->get($category->id, collect())
->map($formatCategory)
->values(),
];
};
return $categories
->filter(fn (Category $category) => $category->categoria_id === null
|| ! $categoryIds->has($category->categoria_id))
->map($formatCategory)
->values();
}
/**
* @param Collection<int, Menu> $menus
* @return Collection<int, array<string, mixed>>
*/
private function menuTree(Collection $menus): Collection
{
$menuCodes = $menus->pluck('code')->flip();
$childrenByParent = $menus
->filter(fn (Menu $menu) => $menu->parent_menu_code !== null)
->groupBy('parent_menu_code');
$formatMenu = function (Menu $menu) use (&$formatMenu, $childrenByParent): array {
$formatted = [
'id' => $menu->id,
'code' => $menu->code,
'label' => $menu->label,
'parent_menu_code' => $menu->parent_menu_code,
'content_type' => $menu->content_type,
'route' => $menu->route,
];
if ($menu->pivot?->static_content !== null) {
$formatted['static_content'] = $menu->pivot->static_content;
}
$formatted['submenues'] = $childrenByParent
->get($menu->code, collect())
->map($formatMenu)
->values();
return $formatted;
};
return $menus
->filter(fn (Menu $menu) => $menu->parent_menu_code === null
|| ! $menuCodes->has($menu->parent_menu_code))
->map($formatMenu)
->values();
}
}

View File

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

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Tenant\Services;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
@@ -9,29 +10,35 @@ use Illuminate\Support\Str;
class TenantService
{
public function __construct(protected AttachmentService $attachmentService)
{
}
public function __construct(
protected AttachmentService $attachmentService,
protected WebsiteExtraService $websiteExtraService,
) {}
/**
* Create a new tenant and store its logos.
*
* @param array<string, mixed> $data
* @return Tenant
*/
public function create(array $data): Tenant
{
return DB::transaction(function () use ($data): Tenant {
$headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null;
$heroBgImage = $data['hero_bg_image'] ?? null;
$socialMedia = $data['social_media'] ?? [];
$extras = $data['extras'] ?? [];
unset($data['header_logo'], $data['footer_logo'], $data['hero_bg_image']);
unset(
$data['header_logo'],
$data['footer_logo'],
$data['social_media'],
$data['extras'],
);
$headerAttachmentId = null;
if ($headerLogo) {
$attachment = Str::isUuid($headerLogo)
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $headerLogo)->first()
? Attachment::query()->where('key', $headerLogo)->first()
: $this->attachmentService->store($headerLogo, 'tenants');
if ($attachment) {
@@ -42,7 +49,7 @@ class TenantService
$footerAttachmentId = null;
if ($footerLogo) {
$attachment = Str::isUuid($footerLogo)
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $footerLogo)->first()
? Attachment::query()->where('key', $footerLogo)->first()
: $this->attachmentService->store($footerLogo, 'tenants');
if ($attachment) {
@@ -53,20 +60,10 @@ class TenantService
$data['header_logo_id'] = $headerAttachmentId;
$data['footer_logo_id'] = $footerAttachmentId;
if ($heroBgImage) {
$attachment = Str::isUuid($heroBgImage)
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $heroBgImage)->first()
: $this->attachmentService->store($heroBgImage, 'tenants');
if ($attachment) {
$heroConfig = $data['hero_config'] ?? [];
$heroConfig['background_image_id'] = $attachment->id;
$data['hero_config'] = $heroConfig;
}
}
/** @var Tenant $tenant */
$tenant = Tenant::query()->create($data);
$this->syncSocialMedia($tenant, $socialMedia);
$this->websiteExtraService->createForTenant($tenant, $extras);
return $tenant;
});
@@ -75,30 +72,30 @@ class TenantService
/**
* Update an existing tenant and store new logos if uploaded.
*
* @param Tenant $tenant
* @param array<string, mixed> $data
* @return Tenant
*/
public function update(Tenant $tenant, array $data): Tenant
{
return DB::transaction(function () use ($tenant, $data): Tenant {
$hasHeaderLogoKey = array_key_exists('header_logo', $data);
$hasFooterLogoKey = array_key_exists('footer_logo', $data);
$hasHeroBgImageKey = array_key_exists('hero_bg_image', $data);
$hasSocialMediaKey = array_key_exists('social_media', $data);
$headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null;
$heroBgImage = $data['hero_bg_image'] ?? null;
$socialMedia = $data['social_media'] ?? [];
unset($data['header_logo'], $data['footer_logo'], $data['hero_bg_image']);
$oldHeroBgId = $tenant->hero_bg_image_id;
unset(
$data['header_logo'],
$data['footer_logo'],
$data['social_media']
);
$tenant->fill($data);
if ($hasHeaderLogoKey) {
if ($headerLogo) {
$attachment = Str::isUuid($headerLogo)
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $headerLogo)->first()
? Attachment::query()->where('key', $headerLogo)->first()
: $this->attachmentService->store($headerLogo, 'tenants');
if ($attachment) {
@@ -114,7 +111,7 @@ class TenantService
if ($hasFooterLogoKey) {
if ($footerLogo) {
$attachment = Str::isUuid($footerLogo)
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $footerLogo)->first()
? Attachment::query()->where('key', $footerLogo)->first()
: $this->attachmentService->store($footerLogo, 'tenants');
if ($attachment) {
@@ -127,31 +124,31 @@ class TenantService
}
}
$currentHeroConfig = $tenant->hero_config ?? [];
if ($hasHeroBgImageKey) {
if ($heroBgImage) {
$attachment = Str::isUuid($heroBgImage)
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $heroBgImage)->first()
: $this->attachmentService->store($heroBgImage, 'tenants');
if ($attachment) {
$currentHeroConfig['background_image_id'] = $attachment->id;
} else {
unset($currentHeroConfig['background_image_id']);
}
} else {
unset($currentHeroConfig['background_image_id']);
}
} else {
if ($oldHeroBgId) {
$currentHeroConfig['background_image_id'] = $oldHeroBgId;
}
}
$tenant->hero_config = empty($currentHeroConfig) ? null : $currentHeroConfig;
$tenant->save();
if ($hasSocialMediaKey) {
$this->syncSocialMedia($tenant, $socialMedia);
}
return $tenant;
});
}
/**
* @param array<int, array{code: string, url: string, orden?: int}> $socialMedia
*/
private function syncSocialMedia(Tenant $tenant, array $socialMedia): void
{
$associations = [];
foreach (array_values($socialMedia) as $index => $item) {
$associations[$item['code']] = [
'url' => $item['url'],
'orden' => $item['orden'] ?? $index,
];
}
$tenant->socialMedia()->sync($associations);
$tenant->unsetRelation('socialMedia');
}
}

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More