Compare commits

..

4 Commits

700 changed files with 8004 additions and 54240 deletions

View File

@@ -3,16 +3,11 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
FRONTEND_URL=http://localhost:4200
INTEGRATION_SECRET=
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
PURCHASE_QR_EXPIRATION_MINUTES=15
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
STOCK_RESERVATION_EXPIRATION_MINUTES=30
FRONTEND_URLS=http://localhost:4200
APP_LOCALE=es
APP_FALLBACK_LOCALE=es
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
@@ -20,43 +15,25 @@ 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=daily
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
LOG_DAILY_DAYS=14
TELEPAGOS_LOG_LEVEL=info
TELEPAGOS_LOG_DAYS=30
COMMANDS_LOG_LEVEL=info
COMMANDS_LOG_DAYS=30
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=
INTEGRATION_SECRET=change-me
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
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
@@ -72,7 +49,7 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
@@ -81,11 +58,13 @@ 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=us-east-1
AWS_DEFAULT_REGION=
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
AWS_HTTP_VERIFY=true
AWS_USE_PATH_STYLE_ENDPOINT=
AWS_HTTP_VERIFY=
VITE_APP_NAME="${APP_NAME}"

File diff suppressed because it is too large Load Diff

View File

@@ -41,18 +41,15 @@
}
],
"url": {
"raw": "{{base_url}}/api/tenants/bootstrap?dominio=acme.com&path=/",
"raw": "{{base_url}}/api/tenants/bootstrap/acme.com",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"bootstrap"
],
"query": [
{"key": "dominio", "value": "acme.com"},
{"key": "path", "value": "/"}
"bootstrap",
"acme.com"
]
}
},

View File

@@ -6,9 +6,6 @@ use App\Domains\Attachable\Enums\AttachmentType;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
#[Fillable([
@@ -43,34 +40,12 @@ class Attachment extends Model
];
}
public function cropVariants(): HasMany
{
return $this->hasMany(AttachmentCrop::class);
}
public function desktopCrop(): HasOne
{
return $this->hasOne(AttachmentCrop::class)
->where('variant', AttachmentCrop::DESKTOP);
}
public function mobileCrop(): HasOne
{
return $this->hasOne(AttachmentCrop::class)
->where('variant', AttachmentCrop::MOBILE);
}
public function cropSource(): HasOne
{
return $this->hasOne(AttachmentCrop::class, 'cropped_attachment_id');
}
/**
* Get the pre-signed temporary S3 URL for this attachment.
*/
public function getTemporaryUrl(int $expiresInMinutes = 10): string
{
return Storage::disk('s3')->temporaryUrl(
return \Illuminate\Support\Facades\Storage::disk('s3')->temporaryUrl(
$this->path,
now()->addMinutes($expiresInMinutes)
);

View File

@@ -1,43 +0,0 @@
<?php
namespace App\Domains\Attachable\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'attachment_id',
'variant',
'crop_horizontal',
'crop_vertical',
'cropped_attachment_id',
])]
class AttachmentCrop extends Model
{
public const DESKTOP = 'desktop';
public const MOBILE = 'mobile';
public const VARIANTS = [self::DESKTOP, self::MOBILE];
protected function casts(): array
{
return [
'attachment_id' => 'integer',
'crop_horizontal' => 'array',
'crop_vertical' => 'array',
'cropped_attachment_id' => 'integer',
];
}
public function attachment(): BelongsTo
{
return $this->belongsTo(Attachment::class);
}
public function croppedAttachment(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'cropped_attachment_id');
}
}

View File

@@ -5,9 +5,7 @@ namespace App\Domains\Attachable\Services;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Exceptions\AttachmentStorageException;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Models\AttachmentCrop;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\Mime\MimeTypes;
@@ -15,107 +13,6 @@ use Throwable;
class AttachmentService
{
/**
* @param array<string, array{crop_horizontal: array<string, mixed>, crop_vertical: array<string, mixed>}> $crops
*/
public function storeCroppedImageVariants(
UploadedFile|string $image,
string $path,
array $crops,
): Attachment {
$crops = $this->validateCropVariants($crops);
$storedPaths = [];
try {
return DB::transaction(function () use (
$image,
$path,
$crops,
&$storedPaths,
): Attachment {
$original = $this->store($image, $path);
$storedPaths[] = $original->path;
$contents = $this->imageContents($image);
foreach ($crops as $variant => $crop) {
$cropped = $this->storeCropVariant($contents, $path, $crop);
$storedPaths[] = $cropped->path;
$original->cropVariants()->create([
'variant' => $variant,
'crop_horizontal' => $crop['crop_horizontal'],
'crop_vertical' => $crop['crop_vertical'],
'cropped_attachment_id' => $cropped->id,
]);
}
return $original->load('cropVariants.croppedAttachment');
});
} catch (Throwable $throwable) {
if ($storedPaths !== []) {
Storage::disk('s3')->delete(array_values(array_unique($storedPaths)));
}
throw $throwable;
}
}
/**
* @param array<string, array{crop_horizontal: array<string, mixed>, crop_vertical: array<string, mixed>}> $crops
*/
public function updateImageCropVariants(
Attachment $original,
array $crops,
): Attachment {
if ($original->type !== AttachmentType::Image) {
throw new AttachmentStorageException('Only image attachments can be cropped.');
}
$crops = $this->validateCropVariants($crops);
$contents = Storage::disk('s3')->get($original->path);
if (! is_string($contents) || $contents === '') {
throw new AttachmentStorageException('The original image could not be read from the s3 disk.');
}
$directory = trim(str_replace('\\', '/', dirname($original->path)), './');
$directory = $directory !== '' ? $directory : 'attachments';
$previousCrops = $original->cropVariants()->with('croppedAttachment')->get();
$storedCrops = [];
try {
foreach ($crops as $variant => $crop) {
$storedCrops[$variant] = $this->storeCropVariant($contents, $directory, $crop);
}
DB::transaction(function () use ($original, $crops, $storedCrops): void {
foreach ($crops as $variant => $crop) {
$original->cropVariants()->updateOrCreate(
['variant' => $variant],
[
'crop_horizontal' => $crop['crop_horizontal'],
'crop_vertical' => $crop['crop_vertical'],
'cropped_attachment_id' => $storedCrops[$variant]->id,
]
);
}
});
} catch (Throwable $throwable) {
foreach ($storedCrops as $storedCrop) {
$this->delete($storedCrop);
}
throw $throwable;
}
foreach ($previousCrops as $previousCrop) {
if ($previousCrop->croppedAttachment !== null) {
$this->delete($previousCrop->croppedAttachment);
}
}
return $original->refresh()->load('cropVariants.croppedAttachment');
}
public function store(
UploadedFile|string $file,
string $path,
@@ -160,66 +57,13 @@ class AttachmentService
public function delete(Attachment $attachment): void
{
$croppedAttachments = $attachment->cropVariants()
->with('croppedAttachment')
->get()
->pluck('croppedAttachment')
->filter();
$paths = $croppedAttachments
->pluck('path')
->prepend($attachment->path)
->filter()
->unique()
->values()
->all();
$deleted = Storage::disk('s3')->delete(
count($paths) === 1 ? $paths[0] : $paths
);
$deleted = Storage::disk('s3')->delete($attachment->path);
if (! $deleted) {
throw new AttachmentStorageException('No se pudieron eliminar los archivos del disco s3.');
throw new AttachmentStorageException('No se pudo eliminar el archivo del disco s3.');
}
DB::transaction(function () use ($attachment, $croppedAttachments): void {
$attachment->delete();
Attachment::query()->whereKey($croppedAttachments->pluck('id'))->delete();
});
}
public function copy(Attachment $source, string $path): Attachment
{
$normalizedPath = $this->normalizeDirectory($path);
if ($normalizedPath === '') {
throw new AttachmentStorageException('The attachment path cannot be empty.');
}
$key = (string) Str::uuid();
$storedPath = $normalizedPath.'/'.$this->buildStoredFilename($key, (string) $source->extension);
$copied = Storage::disk('s3')->copy($source->path, $storedPath);
if (! $copied) {
throw new AttachmentStorageException('No se pudo copiar el archivo en el disco s3.');
}
try {
/** @var Attachment $attachment */
$attachment = Attachment::query()->create([
'key' => $key,
'path' => $storedPath,
'filename' => $source->filename,
'type' => $source->type,
'mime_type' => $source->mime_type,
'extension' => $source->extension,
'size' => $source->size,
]);
return $attachment;
} catch (Throwable $throwable) {
Storage::disk('s3')->delete($storedPath);
throw $throwable;
}
$attachment->delete();
}
protected function normalizeDirectory(string $path): string
@@ -227,171 +71,6 @@ class AttachmentService
return trim($path, '/');
}
/**
* @param array<string, array{crop_horizontal?: mixed, crop_vertical?: mixed}> $crops
* @return array<string, array{crop_horizontal: array{start_percentage: float, end_percentage: float}, crop_vertical: array{start_percentage: float, end_percentage: float}}>
*/
protected function validateCropVariants(array $crops): array
{
$validated = [];
foreach (AttachmentCrop::VARIANTS as $variant) {
$crop = $crops[$variant] ?? null;
if (! is_array($crop)) {
throw new AttachmentStorageException("The {$variant} crop is required.");
}
$horizontal = $crop['crop_horizontal'] ?? null;
$vertical = $crop['crop_vertical'] ?? null;
if (! is_array($horizontal) || ! is_array($vertical)) {
throw new AttachmentStorageException(
"The {$variant} crop must contain crop_horizontal and crop_vertical ranges."
);
}
$validated[$variant] = [
'crop_horizontal' => $this->validateCropRange($horizontal, "{$variant} horizontal"),
'crop_vertical' => $this->validateCropRange($vertical, "{$variant} vertical"),
];
}
return $validated;
}
/**
* @param array{crop_horizontal: array<string, mixed>, crop_vertical: array<string, mixed>} $crop
*/
protected function storeCropVariant(string $contents, string $path, array $crop): Attachment
{
$croppedContents = $this->cropImage(
$contents,
$crop['crop_horizontal'],
$crop['crop_vertical'],
);
return $this->store(
'data:'.$croppedContents['mime_type'].';base64,'.base64_encode($croppedContents['contents']),
$path,
);
}
/**
* @param array{start_percentage?: mixed, end_percentage?: mixed} $range
* @return array{start_percentage: float, end_percentage: float}
*/
protected function validateCropRange(array $range, string $axis): array
{
$start = $range['start_percentage'] ?? null;
$end = $range['end_percentage'] ?? null;
if (! is_numeric($start) || ! is_numeric($end)) {
throw new AttachmentStorageException(
"The {$axis} crop range must contain numeric start_percentage and end_percentage values."
);
}
$start = (float) $start;
$end = (float) $end;
if (! is_finite($start) || ! is_finite($end) || $start < 0 || $end > 100 || $start >= $end) {
throw new AttachmentStorageException(
"The {$axis} crop range must satisfy 0 <= start_percentage < end_percentage <= 100."
);
}
return [
'start_percentage' => $start,
'end_percentage' => $end,
];
}
protected function imageContents(UploadedFile|string $image): string
{
if (is_string($image)) {
return $this->decodeBase64File($image)['data'];
}
$contents = file_get_contents($image->getRealPath());
if (! is_string($contents) || $contents === '') {
throw new AttachmentStorageException('The image content cannot be empty.');
}
return $contents;
}
/**
* @return array{contents: string, mime_type: string}
*/
protected function cropImage(
string $contents,
array $cropHorizontal,
array $cropVertical,
): array {
$source = @imagecreatefromstring($contents);
if ($source === false) {
throw new AttachmentStorageException('The attachment must contain a valid image.');
}
$width = imagesx($source);
$height = imagesy($source);
$x = min($width - 1, (int) floor($width * $cropHorizontal['start_percentage'] / 100));
$right = min($width, (int) ceil($width * $cropHorizontal['end_percentage'] / 100));
$y = min($height - 1, (int) floor($height * $cropVertical['start_percentage'] / 100));
$bottom = min($height, (int) ceil($height * $cropVertical['end_percentage'] / 100));
$cropped = imagecrop($source, [
'x' => $x,
'y' => $y,
'width' => $right - $x,
'height' => $bottom - $y,
]);
if ($cropped === false) {
throw new AttachmentStorageException('The image could not be cropped.');
}
return $this->encodeImage($cropped, $contents);
}
/**
* @return array{contents: string, mime_type: string}
*/
protected function encodeImage(\GdImage $image, string $originalContents): array
{
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($originalContents);
$mimeType = is_string($mimeType) ? strtolower($mimeType) : '';
ob_start();
try {
$encoded = match ($mimeType) {
'image/jpeg' => imagejpeg($image, null, 90),
'image/gif' => imagegif($image),
'image/webp' => imagewebp($image, null, 90),
'image/avif' => function_exists('imageavif') && imageavif($image, null, 90),
default => imagepng($image),
};
$output = ob_get_contents();
} finally {
ob_end_clean();
}
if (! $encoded || ! is_string($output) || $output === '') {
throw new AttachmentStorageException('The cropped image could not be encoded.');
}
return [
'contents' => $output,
'mime_type' => match ($mimeType) {
'image/jpeg', 'image/gif', 'image/webp', 'image/avif' => $mimeType,
default => 'image/png',
},
];
}
protected function resolveFilename(UploadedFile|string $file, string $key): string
{
if ($file instanceof UploadedFile) {

View File

@@ -1,33 +0,0 @@
# Dominio Attachable
## Propósito
Centraliza el almacenamiento y la metadata de archivos adjuntos. Acepta archivos subidos o contenido Base64, los persiste en S3 y registra su tipo, MIME, extensión, tamaño, nombre original y clave única. Para imágenes también permite guardar un original junto con variantes recortadas para desktop y mobile.
## Componentes principales
- `Models/Attachment.php`: representa un adjunto y genera URL temporales de acceso.
- `Models/AttachmentCrop.php`: relaciona un original con su crop desktop o mobile y la imagen generada.
- `Services/AttachmentService.php`: almacena, copia y elimina archivos, compensando en S3 si falla la escritura en base de datos.
- `Enums/AttachmentType.php`: clasifica imágenes, videos, PDF, audio, documentos y otros archivos.
- `Exceptions/AttachmentStorageException.php`: expresa fallos propios del almacenamiento.
## Flujo principal
1. El consumidor entrega un `UploadedFile` o una cadena Base64 y un directorio.
2. El servicio valida el contenido, detecta MIME/extensión y genera una clave UUID.
3. El archivo se guarda en el disco `s3`.
4. Se crea el registro `Attachment`; ante error se elimina el objeto que había sido subido.
## API y dependencias
No expone rutas HTTP propias. Lo consumen otros dominios, especialmente `Catalog` y `Tenant`. Depende de Laravel Storage, Symfony Mime y del modelo `Attachment`.
## Consideraciones
- El directorio no puede quedar vacío después de normalizarlo.
- Cada eje del crop guarda `start_percentage` y `end_percentage`, cumpliendo `0 <= start < end <= 100`.
- `attachment_crops` guarda una fila por variante con los rangos horizontal/vertical y el attachment procesado.
- Al eliminar el original mediante el servicio también se eliminan todas sus variantes recortadas.
- La eliminación se considera fallida si S3 no confirma el borrado.
- Las URL generadas son temporales; el vencimiento predeterminado es de 10 minutos.

View File

@@ -1,42 +0,0 @@
<?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

@@ -1,26 +0,0 @@
<?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

@@ -1,29 +0,0 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\AdminAppCreateResetPasswordAttemptRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class CreateAdminAppResetPasswordAttemptController extends Controller
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
public function __invoke(AdminAppCreateResetPasswordAttemptRequest $request): JsonResponse
{
$this->resetPasswordAttemptService->createForAdminAppEmail(
$request->validated('email'),
);
return response()->json([
'code' => 'auth.password_reset_requested',
'message' => __('api.auth.password_reset_requested'),
'status' => ResetPasswordAttempt::STATUS_PENDING,
], 202);
}
}

View File

@@ -1,32 +0,0 @@
<?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

@@ -1,29 +0,0 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\ScannerCreateResetPasswordAttemptRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class CreateScannerResetPasswordAttemptController extends Controller
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
public function __invoke(ScannerCreateResetPasswordAttemptRequest $request): JsonResponse
{
$this->resetPasswordAttemptService->createForScannerEmail(
$request->validated('email'),
);
return response()->json([
'code' => 'auth.password_reset_requested',
'message' => __('api.auth.password_reset_requested'),
'status' => ResetPasswordAttempt::STATUS_PENDING,
], 202);
}
}

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Services\GoogleAuthService;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class GoogleAuthController extends Controller
{
public function __construct(private readonly GoogleAuthService $googleAuthService) {}
public function redirect(Request $request): RedirectResponse
{
return $this->googleAuthService->redirect($request);
}
public function callback(Request $request): RedirectResponse
{
return $this->googleAuthService->callback($request);
}
}

View File

@@ -1,51 +0,0 @@
<?php
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,
private readonly GuestCartMergeService $guestCartMergeService,
) {}
public function __invoke(GoogleTokenExchangeRequest $request): JsonResponse
{
$authentication = $this->googleAuthService->exchange(
$request->validated('oauth_code'),
$request->validated('tenant_codigo'),
);
$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,60 +3,36 @@
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\Cookie;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class LoginController extends Controller
{
public function __construct(
private readonly GuestCartMergeService $guestCartMergeService,
private readonly PasswordLoginService $passwordLoginService,
) {}
/**
* @throws ValidationException
*/
public function __invoke(LoginUserRequest $request): JsonResponse
{
$credentials = $request->validated();
$user = $this->passwordLoginService->authenticate(
$credentials['email'],
$credentials['password'],
$credentials['tenant_codigo'],
$request->ip(),
$request->userAgent(),
);
$user = User::query()->where('email', $credentials['email'])->first();
$expirationMinutes = (int) config('sanctum.expiration');
$token = $user->createToken(
'api-token',
['*'],
now()->addMinutes($expirationMinutes),
)->plainTextToken;
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => 'Email o Contraseña incorrectos.',
]);
}
$guestTokenCookie = $request->cookie('guest_token');
$guestToken = is_string($guestTokenCookie) && $guestTokenCookie !== ''
? $guestTokenCookie
: null;
$this->guestCartMergeService->merge(
$credentials['tenant_codigo'],
$user,
$guestToken,
);
$token = $user->createToken('api-token')->plainTextToken;
$response = response()->json([
'code' => 'auth.login_success',
'message' => __('api.auth.login_success'),
return response()->json([
'message' => 'Sesion iniciada correctamente.',
'token' => $token,
'token_type' => 'Bearer',
'user' => UserResource::make($user),
]);
if ($guestToken !== null) {
$response->withCookie(Cookie::forget('guest_token'));
}
return $response;
}
}

View File

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

View File

@@ -12,17 +12,15 @@ 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([
'code' => 'auth.register_success',
'message' => __('api.auth.register_success'),
])
->additional(['message' => 'Usuario registrado correctamente.'])
->response()
->setStatusCode(201);
}

View File

@@ -1,41 +0,0 @@
<?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

@@ -1,42 +0,0 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Requests\ScannerLoginRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\PasswordLoginService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class ScannerLoginController extends Controller
{
public function __construct(
private readonly PasswordLoginService $passwordLoginService,
) {}
public function __invoke(ScannerLoginRequest $request): JsonResponse
{
$credentials = $request->validated();
$user = $this->passwordLoginService->authenticateScanner(
$credentials['email'],
$credentials['password'],
$request->ip(),
$request->userAgent(),
);
$expirationMinutes = (int) config('sanctum.expiration');
$token = $user->createToken(
'scanner-token',
['scanner'],
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

@@ -1,24 +0,0 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\User;
use App\Domains\Auth\Resources\ScannerMeResource;
use App\Domains\Auth\Services\ScannerContextService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class ScannerMeController extends Controller
{
public function __construct(
private readonly ScannerContextService $scannerContextService,
) {}
public function __invoke(Request $request): ScannerMeResource
{
/** @var User $user */
$user = $request->user();
return ScannerMeResource::make($this->scannerContextService->load($user));
}
}

View File

@@ -1,40 +0,0 @@
<?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

@@ -1,20 +0,0 @@
<?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

@@ -1,40 +0,0 @@
<?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

@@ -1,42 +0,0 @@
<?php
namespace App\Domains\Auth\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
#[Hidden(['codigo'])]
class ResetPasswordAttempt extends Model
{
public const REASON_MANUAL = 'manual';
public const REASON_ACCOUNT_LOCKED = 'account_locked';
public const REASON_STAFF_CREATED = 'staff_created';
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,86 +2,26 @@
namespace App\Domains\Auth\Models;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Role;
use App\Domains\Catalog\Models\Category;
use App\Domains\Tenant\Models\Tenant;
use Database\Factories\UserFactory;
use 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\BelongsToMany;
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', 'rol_codigo', 'tenant_codigo'])]
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono'])]
#[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');
}
public function hasPermission(string $permissionCode): bool
{
return $this->role()
->whereHas(
'permissions',
fn ($query) => $query->where('permisos.codigo', $permissionCode)
)
->exists();
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/** @return BelongsToMany<Category, $this> */
public function scanCategories(): BelongsToMany
{
return $this->belongsToMany(
Category::class,
'category_scanners',
'user_id',
'categoria_id',
)->withTimestamps();
}
/**
* @return array<string, string>
*/
@@ -90,9 +30,6 @@ 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

@@ -1,31 +0,0 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class AdminAppCreateResetPasswordAttemptRequest 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'],
];
}
}

View File

@@ -1,36 +0,0 @@
<?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

@@ -1,37 +0,0 @@
<?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

@@ -1,22 +0,0 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
class GoogleTokenExchangeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'oauth_code' => ['required', 'uuid'],
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
];
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class LoginUserRequest extends FormRequest
{
@@ -12,17 +11,6 @@ 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>
*/
@@ -31,7 +19,6 @@ class LoginUserRequest extends FormRequest
return [
'email' => ['required', 'string', 'email', 'max:255'],
'password' => ['required', 'string'],
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
];
}
}

View File

@@ -4,7 +4,6 @@ namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
class RegisterUserRequest extends FormRequest
{
@@ -19,10 +18,9 @@ class RegisterUserRequest extends FormRequest
public function rules(): array
{
return [
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
'nombre_apellido' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()],
'password' => ['required', 'string', 'confirmed', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
'dni' => ['nullable', 'string', 'max:255'],
'telefono' => ['nullable', 'string', 'max:255'],
];

View File

@@ -1,43 +0,0 @@
<?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

@@ -1,31 +0,0 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class ScannerCreateResetPasswordAttemptRequest 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'],
];
}
}

View File

@@ -1,5 +0,0 @@
<?php
namespace App\Domains\Auth\Requests;
class ScannerLoginRequest extends AdminAppLoginRequest {}

View File

@@ -1,36 +0,0 @@
<?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

@@ -1,25 +0,0 @@
<?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

@@ -1,21 +0,0 @@
<?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 ScannerMeResource 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,12 +2,11 @@
namespace App\Domains\Auth\Resources;
use App\Domains\Auth\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin User
* @mixin \App\Domains\Auth\Models\User
*/
class UserResource extends JsonResource
{
@@ -22,8 +21,6 @@ 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

@@ -1,26 +0,0 @@
<?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

@@ -1,22 +0,0 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use Illuminate\Support\Facades\Hash;
class AdminCredentialVerifier
{
public function verify(string $email, string $password): bool
{
$admin = User::query()
->where('email', mb_strtolower(trim($email)))
->where('rol_codigo', RoleCode::Admin->value)
->first();
return $admin !== null
&& ! $admin->locked_until?->isFuture()
&& Hash::check($password, $admin->getAuthPassword());
}
}

View File

@@ -1,176 +0,0 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Notification\Events\UserRegistered;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Laravel\Socialite\Contracts\User as SocialiteUser;
use Laravel\Socialite\Facades\Socialite;
class GoogleAuthService
{
public function redirect(Request $request): RedirectResponse
{
$tenantCode = $request->string('tenant')->toString();
$returnUrl = $request->string('return_url')->toString();
$tenant = Tenant::query()->where('codigo', $tenantCode)->first();
if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) {
throw ValidationException::withMessages([
'tenant' => __('api.auth.invalid_tenant_or_return_url'),
]);
}
$state = (string) Str::uuid();
Cache::put("google-oauth-context:{$state}", [
'tenant_codigo' => $tenant->codigo,
'return_url' => rtrim($returnUrl, '/'),
], now()->addMinutes(10));
return Socialite::driver('google')
->scopes(['openid', 'profile', 'email'])
->stateless()
->with(['state' => $state])
->redirect();
}
public function callback(Request $request): RedirectResponse
{
$state = $request->string('state')->toString();
/** @var array{tenant_codigo?: string, return_url?: string}|null $context */
$context = Str::isUuid($state) ? Cache::pull("google-oauth-context:{$state}") : null;
if (! is_array($context) || ! isset($context['tenant_codigo'], $context['return_url'])) {
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, __('api.auth.invalid_return_url'));
}
/** @var SocialiteUser $googleUser */
$googleUser = Socialite::driver('google')->stateless()->user();
$user = $this->resolveUser($googleUser, $tenant);
$token = $user->createToken(
'google-oauth',
['*'],
now()->addMinutes((int) config('sanctum.expiration')),
)->plainTextToken;
$exchangeCode = (string) Str::uuid();
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([
'oauth_code' => $exchangeCode,
]));
}
/** @return array{user: User, token: string, tenant_codigo: string} */
public function exchange(string $exchangeCode, string $tenantCodigo): array
{
/** @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' => __('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,
];
}
private function resolveUser(SocialiteUser $googleUser, Tenant $tenant): User
{
$googleId = $googleUser->getId();
$email = $googleUser->getEmail();
if (! is_string($googleId) || $googleId === '' || ! is_string($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw ValidationException::withMessages([
'google' => __('api.auth.google_email_required'),
]);
}
$rawUser = $googleUser instanceof \Laravel\Socialite\Two\User ? $googleUser->getRaw() : [];
$emailVerified = $rawUser['email_verified'] ?? $rawUser['verified_email'] ?? false;
if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) {
throw ValidationException::withMessages([
'google' => __('api.auth.google_email_unverified'),
]);
}
$user = User::query()->where('google_id', $googleId)->first();
if ($user) {
return $user;
}
$user = User::query()->where('email', $email)->first();
if ($user) {
$user->forceFill(['google_id' => $googleId])->save();
return $user;
}
$name = $googleUser->getName();
$user = User::query()->create([
'nombre_apellido' => is_string($name) && $name !== '' ? $name : $email,
'email' => $email,
'email_verified_at' => now(),
'google_id' => $googleId,
'password' => Str::password(64),
]);
UserRegistered::dispatch($user, $tenant->codigo);
return $user;
}
private function isTenantReturnUrl(string $returnUrl, Tenant $tenant): bool
{
$parts = parse_url($returnUrl);
if (! is_array($parts)
|| ! isset($parts['scheme'], $parts['host'])
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])) {
return false;
}
$scheme = strtolower($parts['scheme']);
$host = TenantDomainNormalizer::normalize($parts['host']);
$tenantDomain = TenantDomainNormalizer::normalize($tenant->dominio);
$returnPath = TenantDomainNormalizer::normalizePath($parts['path'] ?? '/');
if ($host === null
|| $tenantDomain === null
|| $host !== $tenantDomain
|| $returnPath !== $tenant->base_path) {
return false;
}
return $scheme === 'https' || ($scheme === 'http' && in_array($host, ['localhost', '127.0.0.1'], true));
}
}

View File

@@ -1,311 +0,0 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Exceptions\AccountLockedException;
use App\Domains\Auth\Models\LoginAttempt;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\PermissionCode;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Notification\Events\PasswordResetRequested;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class PasswordLoginService
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
/**
* @throws AccountLockedException
* @throws ValidationException
*/
public function authenticate(
string $email,
string $password,
string $tenantCode,
?string $ipAddress,
?string $userAgent,
): User {
return $this->authenticateUser(
$email,
$password,
$tenantCode,
$ipAddress,
$userAgent,
);
}
/**
* Authenticate a tenant-bound AdminApp user without requiring the caller
* to know their tenant code beforehand.
*
* @throws AccountLockedException
* @throws ValidationException
*/
public function authenticateAdminApp(
string $email,
string $password,
?string $ipAddress,
?string $userAgent,
): User {
return $this->authenticateUser(
$email,
$password,
null,
$ipAddress,
$userAgent,
RoleCode::AdminApp,
true,
null,
PasswordResetRequested::CHANNEL_ADMINAPP,
);
}
/**
* Authenticate a tenant-bound user authorized to scan tickets.
*
* @throws AccountLockedException
* @throws ValidationException
*/
public function authenticateScanner(
string $email,
string $password,
?string $ipAddress,
?string $userAgent,
): User {
return $this->authenticateUser(
$email,
$password,
null,
$ipAddress,
$userAgent,
null,
true,
PermissionCode::ScanTickets->value,
PasswordResetRequested::CHANNEL_SCANNER,
);
}
private function authenticateUser(
string $email,
string $password,
?string $tenantCode,
?string $ipAddress,
?string $userAgent,
?RoleCode $requiredRole = RoleCode::User,
bool $requiresTenant = false,
?string $requiredPermission = null,
string $passwordResetChannel = PasswordResetRequested::CHANNEL_STOREFRONT,
): 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,
$requiredPermission,
$passwordResetChannel,
): array {
$user = User::query()
->where('email', $normalizedEmail)
->when(
$requiredRole !== null,
fn ($query) => $query->where('rol_codigo', $requiredRole->value),
)
->when(
$requiredPermission !== null,
fn ($query) => $query->whereHas(
'role.permissions',
fn ($query) => $query->where('permisos.codigo', $requiredPermission)
),
)
->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 && $attemptTenantCode !== null) {
$this->registerFailure(
$user,
$now,
$attemptTenantCode,
$passwordResetChannel,
);
}
$outcome = $user?->locked_until?->isFuture()
? LoginAttempt::OUTCOME_ACCOUNT_LOCKED
: LoginAttempt::OUTCOME_INVALID_CREDENTIALS;
$this->recordAttempt(
$user,
$normalizedEmail,
$attemptTenantCode,
$outcome,
$ipAddress,
$userAgent,
);
return [
'outcome' => $outcome,
'user' => $user,
'locked_until' => $user?->locked_until === null
? null
: CarbonImmutable::instance($user->locked_until),
];
}
$user->forceFill([
'failed_login_attempts' => 0,
'last_failed_login_at' => null,
'locked_until' => null,
])->save();
$this->recordAttempt(
$user,
$normalizedEmail,
$attemptTenantCode,
LoginAttempt::OUTCOME_SUCCESS,
$ipAddress,
$userAgent,
);
return [
'outcome' => LoginAttempt::OUTCOME_SUCCESS,
'user' => $user,
'locked_until' => null,
];
});
if ($result['outcome'] === LoginAttempt::OUTCOME_ACCOUNT_LOCKED) {
throw new AccountLockedException($result['locked_until']);
}
if ($result['outcome'] === LoginAttempt::OUTCOME_INVALID_CREDENTIALS) {
throw ValidationException::withMessages([
'email' => __('api.auth.invalid_credentials'),
]);
}
return $result['user'];
}
private function registerFailure(
User $user,
CarbonImmutable $now,
string $tenantCode,
string $passwordResetChannel,
): void {
$windowMinutes = max(1, (int) config('login-security.attempt_window_minutes'));
$maxAttempts = max(1, (int) config('login-security.max_attempts'));
$lockMinutes = max(1, (int) config('login-security.lock_minutes'));
$withinAttemptWindow = $user->last_failed_login_at !== null
&& $user->last_failed_login_at->gte($now->subMinutes($windowMinutes));
$attempts = $withinAttemptWindow
? $user->failed_login_attempts + 1
: 1;
$previousAttempts = $user->failed_login_attempts;
$user->forceFill([
'failed_login_attempts' => $attempts,
'last_failed_login_at' => $now,
'locked_until' => $attempts >= $maxAttempts
? $now->addMinutes($lockMinutes)
: null,
])->save();
if ($attempts >= $maxAttempts && $previousAttempts < $maxAttempts) {
try {
if ($passwordResetChannel === PasswordResetRequested::CHANNEL_ADMINAPP) {
$this->resetPasswordAttemptService->createForAdminAppEmail(
$user->email,
ResetPasswordAttempt::REASON_ACCOUNT_LOCKED,
);
} elseif ($passwordResetChannel === PasswordResetRequested::CHANNEL_SCANNER) {
$this->resetPasswordAttemptService->createForScannerEmail(
$user->email,
ResetPasswordAttempt::REASON_ACCOUNT_LOCKED,
);
} else {
$this->resetPasswordAttemptService->createForEmail(
$user->email,
$tenantCode,
ResetPasswordAttempt::REASON_ACCOUNT_LOCKED,
);
}
} catch (\Throwable $e) {
Log::error('Failed to trigger reset password on account lock', [
'user_id' => $user->id,
'exception' => $e,
]);
}
}
}
private function recordAttempt(
?User $user,
string $normalizedEmail,
?string $tenantCode,
string $outcome,
?string $ipAddress,
?string $userAgent,
): void {
LoginAttempt::query()->create([
'user_id' => $user?->getKey(),
'email_fingerprint' => hash_hmac(
'sha256',
$normalizedEmail,
(string) config('app.key'),
),
'tenant_codigo' => $tenantCode,
'outcome' => $outcome,
'ip_address' => $ipAddress,
'user_agent' => $userAgent === null
? null
: mb_substr($userAgent, 0, 1024),
]);
}
}

View File

@@ -3,27 +3,20 @@
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Notification\Events\UserRegistered;
class RegisterUserService
{
/**
* @param array{tenant_codigo?: string|null, nombre_apellido: string, email: string, password: string, dni?: string|null, telefono?: string|null} $data
* @param array{nombre_apellido: string, email: string, password: string, dni?: string|null, telefono?: string|null} $data
*/
public function register(array $data): User
{
$user = User::query()->create([
return User::query()->create([
'nombre_apellido' => $data['nombre_apellido'],
'email' => $data['email'],
'password' => $data['password'],
'dni' => $data['dni'] ?? null,
'telefono' => $data['telefono'] ?? null,
]);
if (! empty($data['tenant_codigo'])) {
UserRegistered::dispatch($user, $data['tenant_codigo']);
}
return $user;
}
}

View File

@@ -1,312 +0,0 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Notification\Events\PasswordResetRequested;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
class ResetPasswordAttemptService
{
public function createForEmail(
string $email,
string $tenantCode,
string $reason = ResetPasswordAttempt::REASON_MANUAL,
): void {
$emailFingerprint = $this->emailFingerprint($email);
try {
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
$user = User::query()
->where('email', $email)
->lockForUpdate()
->first();
return $this->createAttemptForUser(
$user,
$reason,
$emailFingerprint,
'Password reset attempt was not created because the user was not found.',
);
});
} catch (Throwable $exception) {
Log::error('Failed to create password reset attempt.', [
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
throw $exception;
}
$this->dispatchPasswordResetRequested(
$attemptId,
$tenantCode,
PasswordResetRequested::CHANNEL_STOREFRONT,
$emailFingerprint,
);
}
public function createForAdminAppEmail(
string $email,
string $reason = ResetPasswordAttempt::REASON_MANUAL,
): void {
$emailFingerprint = $this->emailFingerprint($email);
try {
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
$user = User::query()
->where('email', $email)
->where('rol_codigo', RoleCode::AdminApp->value)
->whereNotNull('tenant_codigo')
->lockForUpdate()
->first();
$attemptId = $this->createAttemptForUser(
$user,
$reason,
$emailFingerprint,
'AdminApp password reset attempt was not created because the user was not found.',
);
if ($user === null || $attemptId === null) {
return null;
}
return [
'attempt_id' => $attemptId,
'tenant_code' => $user->tenant_codigo,
];
});
} catch (Throwable $exception) {
Log::error('Failed to create AdminApp password reset attempt.', [
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
throw $exception;
}
$this->dispatchPasswordResetRequested(
$result['attempt_id'] ?? null,
$result['tenant_code'] ?? null,
PasswordResetRequested::CHANNEL_ADMINAPP,
$emailFingerprint,
);
}
public function createForScannerEmail(
string $email,
string $reason = ResetPasswordAttempt::REASON_MANUAL,
): void {
$emailFingerprint = $this->emailFingerprint($email);
try {
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
$user = User::query()
->where('email', $email)
->where('rol_codigo', RoleCode::Scanner->value)
->whereNotNull('tenant_codigo')
->lockForUpdate()
->first();
$attemptId = $this->createAttemptForUser(
$user,
$reason,
$emailFingerprint,
'Scanner password reset attempt was not created because the user was not found.',
);
if ($user === null || $attemptId === null) {
return null;
}
return [
'attempt_id' => $attemptId,
'tenant_code' => $user->tenant_codigo,
];
});
} catch (Throwable $exception) {
Log::error('Failed to create Scanner password reset attempt.', [
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
throw $exception;
}
$this->dispatchPasswordResetRequested(
$result['attempt_id'] ?? null,
$result['tenant_code'] ?? null,
PasswordResetRequested::CHANNEL_SCANNER,
$emailFingerprint,
);
}
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 createAttemptForUser(
?User $user,
string $reason,
string $emailFingerprint,
string $userNotFoundMessage,
): ?int {
if ($user === null) {
Log::warning($userNotFoundMessage, [
'email_fingerprint' => $emailFingerprint,
]);
return null;
}
$user->resetPasswordAttempts()
->whereIn('status', [
ResetPasswordAttempt::STATUS_PENDING,
ResetPasswordAttempt::STATUS_VALIDATED,
])
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
$attempt = $user->resetPasswordAttempts()->create([
'codigo' => $this->generateCode(),
'reason' => $reason,
'status' => ResetPasswordAttempt::STATUS_PENDING,
]);
return $attempt->getKey();
}
private function dispatchPasswordResetRequested(
?int $attemptId,
?string $tenantCode,
string $channel,
string $emailFingerprint,
): void {
if ($attemptId === null || $tenantCode === null) {
return;
}
try {
PasswordResetRequested::dispatch($attemptId, $tenantCode, $channel);
} catch (Throwable $exception) {
Log::error('Failed to dispatch password reset email.', [
'attempt_id' => $attemptId,
'tenant_code' => $tenantCode,
'channel' => $channel,
'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

@@ -1,24 +0,0 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User;
class ScannerContextService
{
public function load(User $user): User
{
$tenant = $user->tenant()
->with([
'menues' => fn ($query) => $query->whereHas(
'roles',
fn ($query) => $query->where('codigo', $user->rol_codigo)
),
])
->firstOrFail();
$user->setRelation('tenant', $tenant);
return $user;
}
}

View File

@@ -1,36 +0,0 @@
# Dominio Auth
## Propósito
Gestiona identidad y acceso de usuarios de la tienda y del panel administrativo: registro, inicio y cierre de sesión, perfil, autenticación con Google y recuperación de contraseña.
## Modelo y servicios
- `User`: usuario autenticable, asociado a tenant, rol, intentos de acceso y categorías habilitadas para escaneo.
- `LoginAttempt` y `ResetPasswordAttempt`: trazabilidad de accesos y recuperación de contraseña.
- `PasswordLoginService`: autentica tienda y AdminApp, incluyendo bloqueo por intentos.
- `RegisterUserService` y `ProfileService`: alta y edición del usuario.
- `ResetPasswordAttemptService`: crea, valida y consume códigos de recuperación.
- `GoogleAuthService`: redirección, callback e intercambio de código para Google OAuth.
- `AdminAppContextService`: carga el contexto requerido por un usuario administrativo.
## Endpoints públicos
- `POST /register`, `POST /login` y `POST /logout`.
- `GET /me` y `PUT /me`, protegidos por `auth:sanctum`.
- Creación, validación y aplicación de intentos de recuperación bajo `/password`.
- `POST /auth/google/exchange` para canjear el código de autenticación.
- `POST /v1/adminapp/login` y consulta del usuario administrativo dentro del grupo autenticado de AdminApp.
## Validación y respuestas
Los `FormRequest` validan cada operación. `UserResource` y `AdminAppMeResource` definen las representaciones de salida. Los endpoints sensibles aplican `auth:sanctum` y límites de frecuencia.
## Dependencias y eventos
Se relaciona con `Tenant` y `Authorization`; el registro y la recuperación disparan flujos atendidos por `Notification`. El carrito invitado puede integrarse al usuario autenticado mediante el dominio `Cart`.
## Consideraciones
- La resolución del tenant forma parte de la autenticación y no debe omitirse.
- Los cambios en reglas de login deben conservar los límites de intentos y el manejo de `AccountLockedException`.

View File

@@ -1,20 +0,0 @@
<?php
use App\Domains\Auth\Controllers\AdminAppLoginController;
use App\Domains\Auth\Controllers\AdminAppMeController;
use App\Domains\Auth\Controllers\CreateAdminAppResetPasswordAttemptController;
use App\Domains\Auth\Controllers\ResetPasswordController;
use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp')->group(function (): void {
Route::post('login', AdminAppLoginController::class)->middleware('throttle:login');
Route::post('password/reset-attempts', CreateAdminAppResetPasswordAttemptController::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::middleware(['auth:sanctum', 'adminapp.tenant'])
->get('me', AdminAppMeController::class);
});

View File

@@ -1,28 +1,14 @@
<?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)->middleware('throttle:login');
Route::post('/password/reset-attempts', CreateResetPasswordAttemptController::class)
->middleware('throttle:5,1');
Route::post('/password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
->middleware('throttle:10,1');
Route::post('/password/reset', ResetPasswordController::class)
->middleware('throttle:5,1');
Route::post('/auth/google/exchange', GoogleTokenExchangeController::class);
Route::post('/login', LoginController::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';
require __DIR__.'/scanner.php';

View File

@@ -1,20 +0,0 @@
<?php
use App\Domains\Auth\Controllers\CreateScannerResetPasswordAttemptController;
use App\Domains\Auth\Controllers\ResetPasswordController;
use App\Domains\Auth\Controllers\ScannerLoginController;
use App\Domains\Auth\Controllers\ScannerMeController;
use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/scanner')->group(function (): void {
Route::post('login', ScannerLoginController::class)->middleware('throttle:login');
Route::post('password/reset-attempts', CreateScannerResetPasswordAttemptController::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::middleware(['auth:sanctum', 'scanner.tenant'])
->get('me', ScannerMeController::class);
});

View File

@@ -1,8 +0,0 @@
<?php
namespace App\Domains\Authorization\Enums;
enum PermissionCode: string
{
case ScanTickets = 'tickets.escanear';
}

View File

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

View File

@@ -1,32 +0,0 @@
<?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

@@ -1,59 +0,0 @@
<?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

@@ -1,32 +0,0 @@
<?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

@@ -1,26 +0,0 @@
# Dominio Authorization
## Propósito
Define el esquema de roles y permisos usado para autorizar funcionalidades de la aplicación.
## Componentes principales
- `Enums/RoleCode.php`: códigos de roles conocidos por el sistema.
- `Models/Role.php`: rol con relaciones hacia permisos, usuarios y menús.
- `Models/Permission.php`: permiso asignable a uno o más roles.
- `Models/RolePermission.php`: entidad de asociación entre rol y permiso.
## API
No expone controladores ni rutas propias. Su información se consume desde autenticación, menús, políticas y middleware de autorización.
## Relaciones relevantes
- `Role` tiene muchos usuarios del dominio `Auth`.
- Roles y permisos mantienen una relación muchos-a-muchos.
- Los roles determinan los menús disponibles mediante el dominio `Menu`.
## Consideraciones
Los códigos definidos en `RoleCode` funcionan como contrato entre datos persistidos y lógica de aplicación. Al agregar un rol o permiso se deben revisar seeds, asociaciones y consumidores.

View File

@@ -1,20 +0,0 @@
<?php
namespace App\Domains\Bootstrap\Controllers;
use App\Domains\Bootstrap\Requests\AdminAppBootstrapRequest;
use App\Domains\Bootstrap\Resources\AdminAppBootstrapResource;
use App\Domains\Bootstrap\Services\AdminAppBootstrapService;
use App\Http\Controllers\Controller;
class AdminAppBootstrapController extends Controller
{
public function __construct(protected AdminAppBootstrapService $bootstrapService) {}
public function __invoke(AdminAppBootstrapRequest $request): AdminAppBootstrapResource
{
return AdminAppBootstrapResource::make(
$this->bootstrapService->get((string) $request->validated('dominio'))
);
}
}

View File

@@ -1,20 +0,0 @@
<?php
namespace App\Domains\Bootstrap\Controllers;
use App\Domains\Bootstrap\Requests\ScannerBootstrapRequest;
use App\Domains\Bootstrap\Resources\ScannerBootstrapResource;
use App\Domains\Bootstrap\Services\ScannerBootstrapService;
use App\Http\Controllers\Controller;
class ScannerBootstrapController extends Controller
{
public function __construct(protected ScannerBootstrapService $bootstrapService) {}
public function __invoke(ScannerBootstrapRequest $request): ScannerBootstrapResource
{
return ScannerBootstrapResource::make(
$this->bootstrapService->get((string) $request->validated('dominio'))
);
}
}

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Domains\Bootstrap\Controllers;
use App\Domains\Bootstrap\Requests\TenantBootstrapRequest;
use App\Domains\Bootstrap\Services\TenantBootstrapService;
use App\Domains\Tenant\Resources\TenantResource;
use App\Http\Controllers\Controller;
class TenantBootstrapController extends Controller
{
public function __construct(protected TenantBootstrapService $bootstrapService) {}
public function __invoke(TenantBootstrapRequest $request): TenantResource
{
return TenantResource::make(
$this->bootstrapService->get(
(string) $request->validated('dominio'),
(string) $request->validated('path'),
)
);
}
}

View File

@@ -1,5 +0,0 @@
<?php
namespace App\Domains\Bootstrap\Requests;
class AdminAppBootstrapRequest extends TenantBootstrapRequest {}

View File

@@ -1,5 +0,0 @@
<?php
namespace App\Domains\Bootstrap\Requests;
class ScannerBootstrapRequest extends TenantBootstrapRequest {}

View File

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

View File

@@ -1,5 +0,0 @@
<?php
namespace App\Domains\Bootstrap\Resources;
class ScannerBootstrapResource extends AdminAppBootstrapResource {}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Domains\Bootstrap\Services;
use App\Domains\Tenant\Models\WebsiteType;
class AdminAppBootstrapService
{
/** @return array{website_type: WebsiteType} */
public function get(string $domain): array
{
return [
'website_type' => WebsiteType::query()
->with(['siteLogo', 'footerLogo'])
->where('dominio', $domain)
->firstOrFail(),
];
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Domains\Bootstrap\Services;
use App\Domains\Tenant\Models\WebsiteType;
class ScannerBootstrapService
{
/** @return array{website_type: WebsiteType} */
public function get(string $domain): array
{
return [
'website_type' => WebsiteType::query()
->with(['siteLogo', 'footerLogo'])
->where('scanner_domain', $domain)
->firstOrFail(),
];
}
}

View File

@@ -1,43 +0,0 @@
<?php
namespace App\Domains\Bootstrap\Services;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Services\TenantInformationService;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class TenantBootstrapService
{
public function __construct(protected TenantInformationService $tenantInformationService) {}
public function get(string $domain, string $path = '/'): Tenant
{
$candidateBasePaths = TenantDomainNormalizer::basePathCandidates($path);
$tenantsByBasePath = Tenant::query()
->where('dominio', $domain)
->whereIn('base_path', $candidateBasePaths)
->get()
->keyBy('base_path');
$tenant = collect($candidateBasePaths)
->map(fn (string $candidate): ?Tenant => $tenantsByBasePath->get($candidate))
->first(fn (?Tenant $candidate): bool => $candidate !== null);
if (! $tenant instanceof Tenant) {
throw (new ModelNotFoundException)->setModel(Tenant::class);
}
return $this->tenantInformationService->load(
$tenant,
[
'menues' => fn ($query) => $query->whereHas(
'roles',
fn ($query) => $query->where('codigo', RoleCode::User->value)
),
'categories' => fn ($query) => $query->orderBy('nombre'),
]
);
}
}

View File

@@ -1,28 +0,0 @@
# Dominio Bootstrap
## Propósito
Entrega la configuración inicial que necesitan la tienda y el panel administrativo antes de renderizar su interfaz.
## Flujos
- `TenantBootstrapService` resuelve un tenant desde el dominio solicitado y carga su información pública.
- `AdminAppBootstrapService` prepara el contexto inicial del panel administrativo para el tenant autenticado.
- Los controladores invocables transforman el resultado mediante `TenantResource` o `AdminAppBootstrapResource`.
## Endpoints
- `GET /tenants/bootstrap?dominio={hostname}&path={path}`: bootstrap público de la tienda. Resuelve la clave de tenant más específica que sea prefijo completo del path y usa el dominio raíz como fallback.
- Endpoint de bootstrap bajo `/v1/adminapp`, protegido por `auth:sanctum` y `adminapp.tenant`.
## Validación
`TenantBootstrapRequest` valida y normaliza por separado el hostname y el path recibidos. `AdminAppBootstrapRequest` reutiliza ese contrato para el panel.
## Dependencias
Depende principalmente de `Tenant` para resolver y cargar la tienda, y de los dominios que aportan datos al contexto administrativo.
## Consideraciones
Este dominio es un agregador de lectura. Debe mantenerse liviano y delegar la obtención de cada dato al dominio propietario.

View File

@@ -1,9 +0,0 @@
<?php
use App\Domains\Bootstrap\Controllers\AdminAppBootstrapController;
use Illuminate\Support\Facades\Route;
Route::get(
'v1/adminapp/bootstrap/{dominio}',
AdminAppBootstrapController::class
);

View File

@@ -1,9 +0,0 @@
<?php
use App\Domains\Bootstrap\Controllers\TenantBootstrapController;
use Illuminate\Support\Facades\Route;
Route::get('tenants/bootstrap', TenantBootstrapController::class);
require __DIR__.'/adminapp.php';
require __DIR__.'/scanner.php';

View File

@@ -1,9 +0,0 @@
<?php
use App\Domains\Bootstrap\Controllers\ScannerBootstrapController;
use Illuminate\Support\Facades\Route;
Route::get(
'v1/scanner/bootstrap/{dominio}',
ScannerBootstrapController::class
);

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Domains\Bundle\Models;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Casts\Attribute;
use App\Domains\Shared\Contracts\Buyable;
#[Fillable([
'tenant_codigo',
'nombre',
'descripcion',
'precio',
])]
class Bundle extends Model implements Buyable
{
use HasFactory;
protected $table = 'bundles';
protected $appends = ['stock_tecnico'];
protected function casts(): array
{
return [
'precio' => 'decimal:2',
];
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return HasMany<BundleItem, $this>
*/
public function items(): HasMany
{
return $this->hasMany(BundleItem::class, 'bundle_id');
}
public function getPrice(): float
{
return (float) $this->precio;
}
public function getName(): string
{
return $this->nombre ?? 'Bundle';
}
public function incrementRealStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->incrementRealStock($amount * $item->cantidad);
}
}
public function decrementRealStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->decrementRealStock($amount * $item->cantidad);
}
}
public function incrementReservedStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->incrementReservedStock($amount * $item->cantidad);
}
}
public function decrementReservedStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->decrementReservedStock($amount * $item->cantidad);
}
}
public function confirmReservedStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a confirmar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->confirmReservedStock($amount * $item->cantidad);
}
}
public function validateStock(): void
{
if ($this->stock_tecnico <= 0) {
throw new \InvalidArgumentException('El bundle no tiene stock técnico disponible.');
}
}
protected function stockTecnico(): Attribute
{
return Attribute::get(function () {
if ($this->items->isEmpty()) {
return 0;
}
$minStock = PHP_INT_MAX;
foreach ($this->items as $item) {
// Ensure variant is loaded
if ($item->variant) {
$itemStock = floor($item->variant->stock_tecnico / $item->cantidad);
if ($itemStock < $minStock) {
$minStock = $itemStock;
}
}
}
return $minStock === PHP_INT_MAX ? 0 : (int) $minStock;
});
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Domains\Bundle\Models;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'bundle_id',
'producto_variante_id',
'cantidad',
])]
class BundleItem extends Model
{
use HasFactory;
protected $table = 'bundle_items';
protected function casts(): array
{
return [
'bundle_id' => 'integer',
'producto_variante_id' => 'integer',
'cantidad' => 'integer',
];
}
/**
* @return BelongsTo<Bundle, $this>
*/
public function bundle(): BelongsTo
{
return $this->belongsTo(Bundle::class, 'bundle_id');
}
/**
* @return BelongsTo<ProductVariant, $this>
*/
public function variant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
}
}

View File

@@ -2,11 +2,11 @@
namespace App\Domains\Cart\Controllers;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Cart\Requests\AddCartItemRequest;
use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest;
use App\Domains\Cart\Resources\CartResource;
use App\Domains\Cart\Services\CartService;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
@@ -16,7 +16,8 @@ class CartController extends Controller
{
public function __construct(
protected CartService $cartService,
) {}
) {
}
public function show(Request $request, Tenant $tenant): CartResource
{
@@ -28,18 +29,13 @@ class CartController extends Controller
$result = $this->cartService->addItem(
$tenant,
$request,
(int) $request->validated('catalog_item_id'),
$request->validated('variant_id') !== null
? (int) $request->validated('variant_id')
: null,
$request->mappedBuyableType(),
(int) $request->validated('buyable_id'),
(int) $request->validated('cantidad'),
);
$response = CartResource::make($result['cart'])
->additional([
'code' => 'cart.item_added',
'message' => __('api.cart.item_added'),
])
->additional(['message' => 'Producto agregado al carrito.'])
->response();
if ($result['guest_token'] !== null) {
@@ -52,38 +48,22 @@ class CartController extends Controller
public function updateItemQuantity(
UpdateCartItemQuantityRequest $request,
Tenant $tenant,
CartItem $cartItem,
\App\Domains\Cart\Models\CartItem $cartItem,
): CartResource {
$updatesVariant = $request->exists('variant_id');
return CartResource::make(
$this->cartService->updateItem(
$this->cartService->updateItemQuantity(
$tenant,
$request,
$cartItem->getKey(),
(int) $request->validated('cantidad'),
$updatesVariant
? ($request->validated('variant_id') !== null
? (int) $request->validated('variant_id')
: null)
: $cartItem->variant_id,
$updatesVariant,
)
)->additional([
'code' => $updatesVariant ? 'cart.item_updated' : 'cart.quantity_updated',
'message' => $updatesVariant
? __('api.cart.item_updated')
: __('api.cart.quantity_updated'),
]);
)->additional(['message' => 'Cantidad de producto actualizada.']);
}
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
public function removeItem(Request $request, Tenant $tenant, \App\Domains\Cart\Models\CartItem $cartItem): CartResource
{
return CartResource::make(
$this->cartService->removeItem($tenant, $request, $cartItem->getKey())
)->additional([
'code' => 'cart.item_removed',
'message' => __('api.cart.item_removed'),
]);
)->additional(['message' => 'Producto eliminado del carrito.']);
}
}

View File

@@ -0,0 +1,73 @@
<?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

@@ -3,13 +3,7 @@
namespace App\Domains\Cart\Models;
use App\Domains\Auth\Models\User;
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\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -26,8 +20,6 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
'user_id',
'guest_token',
'status',
'origin',
'current_purchase_id',
])]
class Cart extends Model
{
@@ -36,15 +28,10 @@ class Cart extends Model
protected $table = 'carritos';
public const ORIGIN_USER = 'user';
public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout';
protected function casts(): array
{
return [
'user_id' => 'integer',
'current_purchase_id' => 'integer',
];
}
@@ -72,208 +59,95 @@ class Cart extends Model
return $this->hasMany(CartItem::class, 'cart_id');
}
/** @return HasMany<Purchase, $this> */
public function purchases(): HasMany
{
return $this->hasMany(Purchase::class, 'cart_id');
}
/** @return BelongsTo<Purchase, $this> */
public function currentPurchase(): BelongsTo
{
return $this->belongsTo(Purchase::class, 'current_purchase_id');
}
public function getTotalAmount(): float
{
$items = $this->relationLoaded('items')
? $this->getRelation('items')
: $this->items()->with(['catalogItem', 'variant'])->get();
: $this->items()->with('buyable')->get();
return (float) $items->reduce(
fn (float $carry, CartItem $item): float => $carry
+ (($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad),
fn (float $carry, $item): float => $carry + ($item->buyable?->getPrice() * $item->cantidad),
0.0,
);
}
public function addItem(int $catalogItemId, ?int $variantId, int $quantity): CartItem
public function addItem(string $buyableType, int $buyableId, int $quantity): CartItem
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => __('api.cart.positive_quantity'),
'cantidad' => 'La cantidad debe ser mayor a cero.',
]);
}
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
$this->invalidateCurrentCheckout();
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
$cartQuantity = (int) $this->items()
->where('catalog_item_id', $catalogItemId)
->sum('cantidad');
$inventoryService = app(CatalogInventoryService::class);
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
$this->assertUserPurchaseLimit(
$selectedItem,
$cartQuantity + $quantity,
heldQuantity: $cartQuantity,
maximumAddableCeiling: $availableQuantity,
);
return DB::transaction(function () use ($buyableType, $buyableId, $quantity): CartItem {
$buyable = $this->resolveScopedBuyable($buyableType, $buyableId, true);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
if ($buyable->stock_tecnico < $quantity) {
throw ValidationException::withMessages([
'cantidad' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
'cantidad' => "Stock insuficiente para la variante/bundle solicitado. Maximo disponible: {$buyable->stock_tecnico}.",
]);
}
/** @var CartItem|null $item */
$item = $this->items()
->where('catalog_item_id', $catalogItemId)
->where('variant_id', $variantId)
->where('buyable_type', $buyableType)
->where('buyable_id', $buyable->getKey())
->lockForUpdate()
->first();
if ($item === null) {
$item = $this->items()->create([
'catalog_item_id' => $catalogItemId,
'variant_id' => $variantId,
'buyable_type' => $buyableType,
'buyable_id' => $buyable->getKey(),
'cantidad' => $quantity,
]);
} else {
app(StockReservationService::class)->ensure($item, $selectedItem);
$item->cantidad += $quantity;
$item->save();
}
app(StockReservationService::class)->reserve($item, $selectedItem, $quantity);
$buyable->incrementReservedStock($quantity);
return $item->fresh();
});
}
public function updateItem(
int $cartItemId,
int $quantity,
?int $variantId = null,
bool $updateVariant = false,
?int $excludedPurchaseId = null,
): CartItem {
public function updateItem(int $cartItemId, int $quantity): CartItem
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => __('api.cart.positive_quantity'),
'cantidad' => 'La cantidad debe ser mayor a cero.',
]);
}
return DB::transaction(function () use (
$cartItemId,
$quantity,
$variantId,
$updateVariant,
$excludedPurchaseId,
): CartItem {
$this->invalidateCurrentCheckout();
return DB::transaction(function () use ($cartItemId, $quantity): CartItem {
/** @var CartItem $item */
$item = $this->items()
->where('id', $cartItemId)
->lockForUpdate()
->firstOrFail();
$currentSelection = $this->resolveScopedItem(
$item->catalog_item_id,
$item->variant_id,
true,
);
$inventoryService = app(CatalogInventoryService::class);
if ($updateVariant && $variantId !== $item->variant_id) {
$nextSelection = $this->resolveScopedItem(
$item->catalog_item_id,
$variantId,
true,
);
$otherVariantsQuantity = (int) $this->items()
->where('catalog_item_id', $item->catalog_item_id)
->whereKeyNot($item->getKey())
->sum('cantidad');
$nextAvailableQuantity = $inventoryService->availableQuantity($nextSelection);
$this->assertUserPurchaseLimit(
$nextSelection,
$otherVariantsQuantity + $quantity,
$excludedPurchaseId,
$otherVariantsQuantity + $item->cantidad,
$nextAvailableQuantity,
);
app(StockReservationService::class)->release($item, $currentSelection, $item->cantidad);
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
]);
}
$targetItem = $this->items()
->where('catalog_item_id', $item->catalog_item_id)
->where('variant_id', $variantId)
->whereKeyNot($item->getKey())
->lockForUpdate()
->first();
if ($targetItem !== null) {
app(StockReservationService::class)->ensure($targetItem, $nextSelection);
$targetItem->cantidad += $quantity;
$targetItem->save();
app(StockReservationService::class)->reserve($targetItem, $nextSelection, $quantity);
$item->delete();
return $targetItem->fresh();
}
$item->variant_id = $variantId;
$item->cantidad = $quantity;
$item->save();
app(StockReservationService::class)->reserve($item, $nextSelection, $quantity);
return $item->fresh();
}
$buyable = $this->resolveScopedBuyable($item->buyable_type, $item->buyable_id, true);
$delta = $quantity - $item->cantidad;
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
if ($delta > 0) {
$otherVariantsQuantity = (int) $this->items()
->where('catalog_item_id', $item->catalog_item_id)
->whereKeyNot($item->getKey())
->sum('cantidad');
$this->assertUserPurchaseLimit(
$currentSelection,
$otherVariantsQuantity + $quantity,
$excludedPurchaseId,
$otherVariantsQuantity + $item->cantidad,
$availableQuantity,
);
}
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
$maxAvailable = $availableQuantity + $item->cantidad;
if ($delta > 0 && $buyable->stock_tecnico < $delta) {
$maxAvailable = $buyable->stock_tecnico + $item->cantidad;
throw ValidationException::withMessages([
'cantidad' => __('api.cart.max_quantity', ['max' => $maxAvailable]),
'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.",
]);
}
if ($delta > 0) {
app(StockReservationService::class)->reserve($item, $currentSelection, $delta);
}
if ($delta < 0) {
app(StockReservationService::class)->release($item, $currentSelection, abs($delta));
}
$item->cantidad = $quantity;
$item->save();
if ($delta > 0) {
$buyable->incrementReservedStock($delta);
}
if ($delta < 0) {
$buyable->decrementReservedStock(abs($delta));
}
return $item->fresh();
});
}
@@ -281,184 +155,42 @@ class Cart extends Model
public function removeItem(int $cartItemId): void
{
DB::transaction(function () use ($cartItemId): void {
$this->invalidateCurrentCheckout();
/** @var CartItem $item */
$item = $this->items()
->where('id', $cartItemId)
->lockForUpdate()
->firstOrFail();
$selectedItem = $this->resolveScopedItem(
$item->catalog_item_id,
$item->variant_id,
true,
);
app(StockReservationService::class)->release(
$item,
$selectedItem,
$item->cantidad,
);
$buyable = $this->resolveScopedBuyable($item->buyable_type, $item->buyable_id, true);
$buyable->decrementReservedStock($item->cantidad);
$item->delete();
});
}
private function invalidateCurrentCheckout(): void
protected function resolveScopedBuyable(string $buyableType, int $buyableId, bool $lockForUpdate = false)
{
$candidatePurchaseId = self::query()
->whereKey($this->getKey())
->value('current_purchase_id');
$currentPurchase = $candidatePurchaseId === null
? null
: Purchase::query()->lockForUpdate()->find($candidatePurchaseId);
/** @var self $cart */
$cart = self::query()->lockForUpdate()->findOrFail($this->getKey());
if ($cart->current_purchase_id !== $candidatePurchaseId) {
if ($cart->current_purchase_id !== null) {
throw ValidationException::withMessages([
'cart' => __('api.purchase.checkout_in_progress'),
]);
}
$this->current_purchase_id = null;
return;
if ($buyableType === \App\Domains\Catalog\Models\ProductVariant::class) {
$query = \App\Domains\Catalog\Models\ProductVariant::query()
->whereKey($buyableId)
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo));
} elseif ($buyableType === \App\Domains\Bundle\Models\Bundle::class) {
$query = \App\Domains\Bundle\Models\Bundle::query()
->whereKey($buyableId)
->where('tenant_codigo', $this->tenant_codigo);
} else {
throw new \InvalidArgumentException('Invalid buyable type');
}
if ($currentPurchase === null) {
return;
}
if ($currentPurchase->status === Purchase::STATUS_PAID) {
throw ValidationException::withMessages([
'cart' => __('api.cart.editing_disabled'),
]);
}
if (in_array($currentPurchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
$currentPurchase->update([
'status' => Purchase::STATUS_SUPERSEDED,
'expires_at' => null,
]);
}
app(StockReservationService::class)->detachFromPurchase($currentPurchase);
self::query()
->whereKey($cart->getKey())
->where('current_purchase_id', $currentPurchase->getKey())
->update(['current_purchase_id' => null]);
$this->current_purchase_id = null;
}
protected function resolveScopedItem(
int $catalogItemId,
?int $variantId,
bool $lockForUpdate = false,
): CatalogItem|Variant {
$catalogItemQuery = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $this->tenant_codigo);
if ($lockForUpdate) {
$catalogItemQuery->lockForUpdate();
}
$catalogItem = $catalogItemQuery->first();
if ($catalogItem === null) {
throw new NotFoundHttpException('Catalog item not found for tenant.');
}
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.bundle_variant_forbidden'),
]);
}
if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([
'catalog_item_id' => __('api.cart.empty_bundle'),
]);
}
return $catalogItem;
}
if ($variantId === null) {
if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.variant_required'),
]);
}
$inventory = $this->resolveInventory($catalogItem->inventory_id, $lockForUpdate);
$catalogItem->setRelation('inventory', $inventory);
return $catalogItem;
}
$variantQuery = Variant::query()
->whereKey($variantId)
->where('catalog_item_id', $catalogItem->id);
if ($lockForUpdate) {
$variantQuery->lockForUpdate();
}
$variant = $variantQuery->first();
if ($variant === null) {
throw new NotFoundHttpException('Variant not found for catalog item.');
}
$inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate);
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation('inventory', $inventory);
return $variant;
}
private function assertUserPurchaseLimit(
CatalogItem|Variant $selectedItem,
int $cartQuantity,
?int $excludedPurchaseId = null,
int $heldQuantity = 0,
?int $maximumAddableCeiling = null,
): void {
if ($this->user_id === null) {
return;
}
$catalogItem = $selectedItem instanceof Variant
? $selectedItem->catalogItem
: $selectedItem;
app(UserPurchaseLimitService::class)->assertCanPurchase(
$catalogItem,
$this->user_id,
$cartQuantity,
$excludedPurchaseId,
$this->getKey(),
$heldQuantity,
$maximumAddableCeiling,
field: 'cantidad',
);
}
protected function resolveInventory(int $inventoryId, bool $lockForUpdate): Inventory
{
$query = Inventory::query()->whereKey($inventoryId);
if ($lockForUpdate) {
$query->lockForUpdate();
}
return $query->firstOrFail();
$buyable = $query->first();
if ($buyable === null) {
throw new NotFoundHttpException('Buyable not found for tenant.');
}
return $buyable;
}
}

View File

@@ -2,19 +2,16 @@
namespace App\Domains\Cart\Models;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Models\ProductVariant;
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([
'cart_id',
'catalog_item_id',
'variant_id',
'buyable_id',
'buyable_type',
'cantidad',
])]
class CartItem extends Model
@@ -27,8 +24,8 @@ class CartItem extends Model
{
return [
'cart_id' => 'integer',
'catalog_item_id' => 'integer',
'variant_id' => 'integer',
'buyable_id' => 'integer',
'buyable_type' => 'string',
'cantidad' => 'integer',
];
}
@@ -41,26 +38,11 @@ class CartItem extends Model
return $this->belongsTo(Cart::class, 'cart_id');
}
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
/**
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function buyable()
{
return $this->belongsTo(CatalogItem::class);
}
/** @return BelongsTo<Variant, $this> */
public function variant(): BelongsTo
{
return $this->belongsTo(Variant::class);
}
public function selectedItem(): CatalogItem|Variant|null
{
return $this->variant ?? $this->catalogItem;
}
/** @return HasMany<StockReservation, $this> */
public function stockReservations(): HasMany
{
return $this->hasMany(StockReservation::class);
return $this->morphTo();
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Domains\Cart\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AddCartItemRequest extends FormRequest
{
@@ -17,25 +16,19 @@ class AddCartItemRequest extends FormRequest
*/
public function rules(): array
{
$tenantCode = $this->route('tenant')?->codigo;
return [
'catalog_item_id' => [
'required',
'integer',
Rule::exists('catalog_items', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variant_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('variantes', 'id')->where(
fn ($query) => $query->where('catalog_item_id', $this->input('catalog_item_id'))
),
],
'buyable_type' => ['required', 'string', \Illuminate\Validation\Rule::in(['variant', 'bundle'])],
'buyable_id' => ['required', 'integer'],
'cantidad' => ['required', 'integer', 'min:1'],
];
}
public function mappedBuyableType(): string
{
return match ($this->input('buyable_type')) {
'variant' => \App\Domains\Catalog\Models\ProductVariant::class,
'bundle' => \App\Domains\Bundle\Models\Bundle::class,
default => throw new \InvalidArgumentException('Invalid buyable type'),
};
}
}

View File

@@ -18,8 +18,6 @@ class UpdateCartItemQuantityRequest extends FormRequest
{
return [
'cantidad' => ['required', 'integer', 'min:1'],
'catalog_item_id' => ['prohibited'],
'variant_id' => ['sometimes', 'nullable', 'integer'],
];
}
}

View File

@@ -2,15 +2,11 @@
namespace App\Domains\Cart\Resources;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin CartItem
* @mixin \App\Domains\Cart\Models\CartItem
*/
class CartItemResource extends JsonResource
{
@@ -19,51 +15,40 @@ class CartItemResource extends JsonResource
*/
public function toArray(Request $request): array
{
$selectedItem = $this->selectedItem();
/** @var \App\Domains\Shared\Contracts\Buyable|null $buyable */
$buyable = $this->buyable;
$productName = $buyable?->getName();
$precio = $buyable?->getPrice();
$imageUrl = null;
$tenant = $request->route('tenant');
$displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images;
$includeVariants = $tenant instanceof Tenant
&& $tenant->cart_editing_policy->allowsVariantChanges();
if ($displayImage && $selectedItem?->relationLoaded('attachments')) {
$imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440);
}
if ($displayImage && $imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
$imageUrl = $this->catalogItem->attachments->first()?->getTemporaryUrl(1440);
if ($this->buyable_type === \App\Domains\Catalog\Models\ProductVariant::class && $buyable && $buyable->relationLoaded('attachments')) {
$firstAttachment = $buyable->attachments->first();
if ($firstAttachment) {
$imageUrl = $firstAttachment->getTemporaryUrl(1440);
}
}
return [
'id' => $this->id,
'cantidad' => $this->cantidad,
'precio_unitario' => $this->formatMoney($selectedItem?->getPrice()),
'catalog_item_id' => $this->catalog_item_id,
'variant_id' => $this->variant_id,
'nombre' => $selectedItem?->getName(),
'imagen' => $imageUrl,
'variant' => $this->variant === null ? null : $this->variantData($this->variant),
'variants' => $this->when(
$includeVariants,
fn () => $this->catalogItem
->visibleVariants($this->variant_id)
->map(fn (Variant $variant): array => $this->variantData($variant))
->values(),
),
'precio_unitario' => $this->formatMoney($precio),
'buyable_type' => $this->mapBuyableTypeToAlias($this->buyable_type),
'buyable_id' => $this->buyable_id,
'product' => $buyable === null ? null : [
'nombre' => $productName,
'imagen' => $imageUrl,
],
];
}
/** @return array<string, mixed> */
protected function variantData(Variant $variant): array
protected function mapBuyableTypeToAlias(?string $type): string
{
return [
'id' => $variant->id,
'precio' => $this->formatMoney($variant->precio ?? $this->catalogItem->precio),
'stock_tecnico' => $this->catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock(),
'values' => $variant->selectorOptions($this->catalogItem->itemAttributes),
];
return match ($type) {
\App\Domains\Catalog\Models\ProductVariant::class => 'variant',
\App\Domains\Bundle\Models\Bundle::class => 'bundle',
default => 'unknown',
};
}
protected function formatMoney(float|int|string|null $amount): string

View File

@@ -2,12 +2,11 @@
namespace App\Domains\Cart\Resources;
use App\Domains\Cart\Models\Cart;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Cart
* @mixin \App\Domains\Cart\Models\Cart
*/
class CartResource extends JsonResource
{
@@ -21,8 +20,7 @@ class CartResource extends JsonResource
: collect();
$subtotal = $items->reduce(
fn (float $carry, $item): float => $carry
+ ((float) ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad),
fn (float $carry, $item): float => $carry + ((float) ($item->variant?->product?->precio ?? 0) * $item->cantidad),
0.0,
);

View File

@@ -2,13 +2,11 @@
namespace App\Domains\Cart\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -28,90 +26,55 @@ class CartService
return $this->makeEmptyCart($tenant);
}
return $this->loadCart($cart, $tenant);
return $this->loadCart($cart);
}
/**
* @return array{cart: Cart, guest_token: ?string}
*/
public function addItem(
Tenant $tenant,
Request $request,
int $catalogItemId,
?int $variantId,
int $quantity,
): array {
public function addItem(Tenant $tenant, Request $request, string $buyableType, int $buyableId, int $quantity): array
{
$resolvedIdentity = $this->resolveIdentity($request, true);
$identity = $resolvedIdentity['identity'];
$cart = $this->findOrCreateCart($tenant, $identity);
$cart->addItem($catalogItemId, $variantId, $quantity);
$cart->addItem($buyableType, $buyableId, $quantity);
return [
'cart' => $this->loadCart($cart, $tenant),
'cart' => $this->loadCart($cart),
'guest_token' => $resolvedIdentity['generated_guest_token'],
];
}
public function updateItem(
Tenant $tenant,
Request $request,
int $cartItemId,
int $quantity,
?int $variantId,
bool $updateVariant,
): Cart {
if ($updateVariant && ! $tenant->cart_editing_policy->allowsVariantChanges()) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.variant_change_disabled'),
]);
}
if (! $updateVariant && ! $tenant->cart_editing_policy->allowsQuantityChanges()) {
throw ValidationException::withMessages([
'cantidad' => __('api.cart.editing_disabled'),
]);
}
public function updateItemQuantity(Tenant $tenant, Request $request, int $cartItemId, int $quantity): Cart
{
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant);
$cart->updateItem($cartItemId, $quantity);
return $this->loadCart($cart, $tenant);
return $this->loadCart($cart);
}
public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart
{
if (! $tenant->cart_editing_policy->allowsRemoval()) {
throw ValidationException::withMessages([
'cart_item' => __('api.cart.editing_disabled'),
]);
}
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->removeItem($cartItemId);
return $this->loadCart($cart, $tenant);
return $this->loadCart($cart);
}
public function makeGuestTokenCookie(string $guestToken): Cookie
{
$secure = (bool) config('session.secure');
$sameSite = config('session.same_site');
$partitioned = (bool) config('session.partitioned')
|| ($secure && strtolower((string) $sameSite) === 'none');
return Cookie::create(
name: 'guest_token',
value: $guestToken,
expire: now()->addDays(180),
path: '/',
domain: config('session.domain'),
secure: $secure,
httpOnly: true,
raw: false,
sameSite: $sameSite,
partitioned: $partitioned,
return cookie(
'guest_token',
$guestToken,
60 * 24 * 180,
'/',
null,
false,
true,
false,
'lax',
);
}
@@ -127,32 +90,13 @@ class CartService
return $cart;
}
protected function loadCart(Cart $cart, Tenant $tenant): Cart
protected function loadCart(Cart $cart): Cart
{
$relations = [
'items.catalogItem.attachments',
'items.catalogItem.inventory',
'items.catalogItem.itemAttributes.attribute',
return $cart->fresh()->load([
'items.variant.product',
'items.variant.definitions.productAttribute.attribute',
'items.variant.attachments',
'items.variant.inventory',
'items.variant.definitions.itemAttribute.attribute.options',
'items.variant.eventDates',
'items.variant.eventDate',
];
if ($tenant->cart_editing_policy->allowsVariantChanges()) {
$relations = [
...$relations,
'items.catalogItem.variants' => fn ($query) => $query->orderBy('id'),
'items.catalogItem.variants.inventory',
'items.catalogItem.variants.definitions' => fn ($query) => $query->orderBy('id'),
'items.catalogItem.variants.definitions.itemAttribute.attribute.options',
'items.catalogItem.variants.eventDates',
'items.catalogItem.variants.eventDate',
];
}
return $cart->fresh()->load($relations);
]);
}
/**
@@ -160,7 +104,7 @@ class CartService
*/
protected function resolveIdentity(Request $request, bool $generateGuestToken = false): ?array
{
$user = $request->user() ?? Auth::guard('sanctum')->user();
$user = $request->user() ?? \Illuminate\Support\Facades\Auth::guard('sanctum')->user();
if ($user instanceof User) {
return [
@@ -220,7 +164,6 @@ 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']),
@@ -248,10 +191,7 @@ class CartService
*/
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
{
$attributes = [
'tenant_codigo' => $tenant->codigo,
'status' => 'active',
];
$attributes = ['tenant_codigo' => $tenant->codigo];
if ($identity['user_id'] !== null) {
$attributes['user_id'] = $identity['user_id'];
@@ -259,6 +199,7 @@ class CartService
$attributes['guest_token'] = $identity['guest_token'];
}
return Cart::query()->firstOrCreate($attributes);
return Cart::query()->firstOrCreate($attributes, ['status' => 'active']);
}
}

View File

@@ -1,123 +0,0 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use Illuminate\Support\Facades\DB;
class ExpireCartReservationsService
{
public function expireOverdue(): int
{
$expiredItems = 0;
$lastCartItemId = 0;
do {
$cartItemIds = StockReservation::query()
->where('status', StockReservation::STATUS_ACTIVE)
->whereNull('purchase_id')
->whereNotNull('cart_item_id')
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->where('cart_item_id', '>', $lastCartItemId)
->whereHas('cartItem.cart', fn ($query) => $query->where('status', 'active'))
->select('cart_item_id')
->distinct()
->orderBy('cart_item_id')
->limit(500)
->pluck('cart_item_id');
foreach ($cartItemIds as $cartItemId) {
$lastCartItemId = (int) $cartItemId;
if ($this->expireCartItem($lastCartItemId)) {
$expiredItems++;
}
}
} while ($cartItemIds->count() === 500);
return $expiredItems;
}
private function expireCartItem(int $cartItemId): bool
{
/** @var CartItem|null $candidate */
$candidate = CartItem::query()->select(['id', 'cart_id'])->find($cartItemId);
if ($candidate === null) {
return false;
}
return DB::transaction(function () use ($candidate, $cartItemId): bool {
/** @var Cart|null $cart */
$cart = Cart::query()
->whereKey($candidate->cart_id)
->where('status', 'active')
->lockForUpdate()
->first();
if ($cart === null) {
return false;
}
/** @var CartItem|null $cartItem */
$cartItem = $cart->items()
->whereKey($cartItemId)
->lockForUpdate()
->first();
if ($cartItem === null) {
return false;
}
$reservations = StockReservation::query()
->where('cart_item_id', $cartItem->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->orderBy('inventory_id')
->lockForUpdate()
->get();
if (
$reservations->isEmpty()
|| $reservations->contains(
fn (StockReservation $reservation): bool => $reservation->purchase_id !== null
|| $reservation->expires_at === null
|| $reservation->expires_at->isFuture(),
)
) {
return false;
}
$inventories = Inventory::query()
->whereKey($reservations->pluck('inventory_id'))
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
foreach ($reservations as $reservation) {
$inventory = $inventories->get($reservation->inventory_id)
?? throw new \InvalidArgumentException('No se encontro el inventario reservado.');
$inventory->release((int) $reservation->quantity);
$reservation->update([
'quantity' => 0,
'status' => StockReservation::STATUS_EXPIRED,
'expires_at' => null,
'released_at' => now(),
]);
}
$cartItem->delete();
if (! $cart->items()->exists()) {
$cart->update(['status' => 'expired']);
$cart->delete();
}
return true;
});
}
}

View File

@@ -1,53 +0,0 @@
<?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->items()
->orderBy('id')
->pluck('id')
->each(fn (int $itemId) => $userCart->removeItem($itemId));
$userCart->update([
'status' => 'converted',
]);
$userCart->delete();
}
$guestCart->update([
'user_id' => $user->getKey(),
'guest_token' => null,
]);
});
}
}

View File

@@ -1,37 +0,0 @@
# Dominio Cart
## Propósito
Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios autenticados.
## Modelo
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems.
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; sólo persiste la selección y cantidad, y expone siempre los datos vigentes del catálogo.
## Servicios
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
- `ExpireCartReservationsService`: libera las reservas vencidas de carritos activos y elimina los carritos que quedan vacíos.
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
## Endpoints
Bajo `/tenants/{tenant:codigo}`:
- `GET /cart`.
- `POST /cart/items`.
- `PATCH /cart/items/{cartItem}`.
- `DELETE /cart/items/{cartItem}`.
## Contratos
`AddCartItemRequest` y `UpdateCartItemQuantityRequest` validan selección y cantidad. `CartResource` y `CartItemResource` estabilizan la respuesta pública. Cada ítem expone `nombre`, `imagen` y `precio_unitario` en la raíz, priorizando la variante seleccionada y usando el catálogo base como respaldo. La variante seleccionada se serializa en `variant`; las variantes alternativas no forman parte de la respuesta del carrito.
## Dependencias y reglas
Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y de `Auth` cuando existe usuario. Toda operación debe comprobar que carrito e ítem pertenecen al tenant actual.
Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técnico con `origin=direct_checkout`; los carritos normales conservan `origin=user` y pueden restaurarse al cancelar o vencer la compra.
El comando unificado `php artisan reservations:expire` procesa primero las compras vencidas y luego las reservas activas sin compra cuyo `expires_at` haya vencido. Se ejecuta cada minuto mediante el scheduler, conserva la fila de reserva con estado `expired`, elimina el ítem abandonado y elimina lógicamente el carrito cuando queda vacío. Cada intento registra sus resultados o su error en el log diario `storage/logs/commands/commands-AAAA-MM-DD.log`.

View File

@@ -4,6 +4,7 @@ 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

@@ -1,65 +0,0 @@
<?php
namespace App\Domains\Catalog\Controllers\AdminApp;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Requests\AdminApp\UpsertOnTicketFeaturedGroupRequest;
use App\Domains\Catalog\Resources\AdminApp\OnTicketFeaturedGroupResource;
use App\Domains\Catalog\Services\OnTicketFeaturedGroupService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class OnTicketFeaturedGroupController extends Controller
{
public function __construct(
private readonly OnTicketFeaturedGroupService $featuredGroupService,
) {}
public function index(Request $request): AnonymousResourceCollection
{
return OnTicketFeaturedGroupResource::collection(
$this->featuredGroupService->forTenant($this->onTicketTenant($request))
);
}
public function store(UpsertOnTicketFeaturedGroupRequest $request): JsonResponse
{
$featuredGroup = $this->featuredGroupService->create(
$this->onTicketTenant($request),
$request->validated(),
);
return OnTicketFeaturedGroupResource::make($featuredGroup)
->response()
->setStatusCode(201);
}
public function update(
UpsertOnTicketFeaturedGroupRequest $request,
FeaturedGroup $featuredGroup,
): OnTicketFeaturedGroupResource {
$tenant = $this->onTicketTenant($request);
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
return OnTicketFeaturedGroupResource::make(
$this->featuredGroupService->update(
$tenant,
$featuredGroup,
$request->validated(),
)
);
}
private function onTicketTenant(Request $request): Tenant
{
$tenant = $request->user()->tenant()->firstOrFail();
abort_unless($tenant->website_type_code === 'onticket', 404);
return $tenant;
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Requests\StoreAttributeRequest;
use App\Domains\Catalog\Requests\UpdateAttributeRequest;
use App\Domains\Catalog\Resources\AttributeResource;
use App\Domains\Catalog\Services\ProductService;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class AttributeController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
$query = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->with('options')
->latest();
return AttributeResource::collection($query->paginateFromRequest())->response();
}
public function store(StoreAttributeRequest $request, Tenant $tenant): JsonResponse
{
$attribute = ProductService::createAttribute($tenant, $request->validated());
return AttributeResource::make($attribute)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, Attribute $attribute): AttributeResource
{
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
return AttributeResource::make($attribute->load('options'));
}
public function update(UpdateAttributeRequest $request, Tenant $tenant, Attribute $attribute): AttributeResource
{
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
$attribute = ProductService::updateAttribute($attribute, $request->validated());
return AttributeResource::make($attribute);
}
public function destroy(Tenant $tenant, Attribute $attribute): Response
{
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
ProductService::deleteAttribute($attribute);
return response()->noContent();
}
protected function resolveScopedAttribute(Tenant $tenant, Attribute $attribute): Attribute
{
if ($attribute->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Attribute not found for tenant.');
}
return $attribute;
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Brand;
use App\Domains\Catalog\Requests\StoreBrandRequest;
use App\Domains\Catalog\Requests\UpdateBrandRequest;
use App\Domains\Catalog\Resources\BrandResource;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class BrandController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
return BrandResource::collection(
Brand::query()->where('tenant_codigo', $tenant->codigo)->orderByDesc('id')->paginateFromRequest()
)->response();
}
public function store(StoreBrandRequest $request, Tenant $tenant): JsonResponse
{
$brand = Brand::query()->create([
...$request->validated(),
'tenant_codigo' => $tenant->codigo,
]);
return BrandResource::make($brand)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, Brand $marca): BrandResource
{
$marca = $this->resolveScopedBrand($tenant, $marca);
return BrandResource::make($marca);
}
public function update(UpdateBrandRequest $request, Tenant $tenant, Brand $marca): BrandResource
{
$marca = $this->resolveScopedBrand($tenant, $marca);
$marca->update($request->validated());
return BrandResource::make($marca);
}
public function destroy(Tenant $tenant, Brand $marca): Response
{
$marca = $this->resolveScopedBrand($tenant, $marca);
$marca->delete();
return response()->noContent();
}
protected function resolveScopedBrand(Tenant $tenant, Brand $brand): Brand
{
if ($brand->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Brand not found for tenant.');
}
return $brand;
}
}

View File

@@ -2,190 +2,22 @@
namespace App\Domains\Catalog\Controllers;
use App\Domains\Cart\Services\CartService;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
use App\Domains\Catalog\Requests\CatalogVariantOptionsRequest;
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\CatalogItemDetailResource;
use App\Domains\Catalog\Resources\CatalogItemResource;
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
use App\Domains\Catalog\Resources\CatalogVariantOptionsResource;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Catalog\Services\FeaturedGroupService;
use App\Domains\Catalog\Services\VariantSelectionService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use App\Domains\Catalog\Models\FeaturedGroup;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Routing\Controller;
class CatalogController extends Controller
{
public function index(Request $request, Tenant $tenant, FeaturedGroupService $featuredGroupService): JsonResponse
public function index(string $tenant): JsonResponse
{
$featuredGroups = FeaturedGroup::query()
->where('tenant_code', $tenant->codigo)
$featuredGroups = FeaturedGroup::where('tenant_codigo', $tenant)
->with(['featuredVariants' => function ($query) {
$query->orderBy('order');
}, 'featuredVariants.variant.product', 'featuredVariants.variant.attachments', 'featuredVariants.variant.product.attachments'])
->orderBy('group_order')
->get();
return response()->json($featuredGroups->map(
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
$featuredGroup,
$featuredGroupService->itemsResponse($featuredGroup, 1, $this->userId($request)),
))->resolve()
));
}
public function search(
SearchCatalogItemsRequest $request,
Tenant $tenant,
CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): AnonymousResourceCollection {
$items = $catalogService->search(
$tenant,
$request->validated('q'),
$tenant->search_items_per_page,
(int) $request->validated('page', 1),
);
$allowances->attach($items->getCollection(), $this->userId($request));
return CatalogSearchItemResource::collection($items);
}
public function category(
CategoryPageRequest $request,
Tenant $tenant,
Category $category,
CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): AnonymousResourceCollection {
abort_unless($category->tenant_code === $tenant->codigo, 404);
$items = $catalogService->categoryItems(
$tenant,
$category,
$tenant->search_items_per_page,
(int) $request->validated('page', 1),
);
$allowances->attach($items->getCollection(), $this->userId($request));
return CatalogSearchItemResource::collection($items)->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,
FeaturedGroup $featuredGroup,
FeaturedGroupService $featuredGroupService,
): JsonResponse {
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
$page = (int) $request->validated('page', 1);
return response()->json($featuredGroupService->itemsResponse(
$featuredGroup,
$page,
$this->userId($request),
));
}
public function show(
CatalogItemDetailRequest $request,
Tenant $tenant,
CatalogItem $catalogItem,
CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): CatalogItemDetailResource {
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
$variantId = $request->validated('variant_id');
$item = $catalogService->getDetail(
$catalogItem,
$variantId === null ? null : (int) $variantId,
);
$allowances->attach(collect([$item]), $this->userId($request));
abort_unless($allowances->availability(
$item->availableStock(),
$item->getAttribute('remaining_user_quota'),
)->isVisible(), 404);
return CatalogItemDetailResource::make($item);
}
public function variantOptions(
CatalogVariantOptionsRequest $request,
Tenant $tenant,
CatalogItem $catalogItem,
VariantSelectionService $variantSelectionService,
CartService $cartService,
): CatalogVariantOptionsResource {
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
$includedVariantId = null;
$cartItemId = $request->validated('cart_item_id');
$selectedValues = $request->validated('selected_values', []);
if ($cartItemId !== null) {
$cartItem = $cartService->show($tenant, $request)
->items
->firstWhere('id', (int) $cartItemId);
abort_unless($cartItem?->catalog_item_id === $catalogItem->id, 404);
$includedVariantId = $cartItem->variant_id;
if ($selectedValues === [] && $cartItem->variant !== null) {
$selectedValues = $cartItem->variant
->selectorOptions($catalogItem->itemAttributes)
->all();
}
}
return CatalogVariantOptionsResource::make(
$variantSelectionService->options(
$catalogItem,
$selectedValues,
$includedVariantId,
)
);
}
public function store(
StoreCatalogItemRequest $request,
Tenant $tenant,
CatalogService $catalogService,
): JsonResponse {
$catalogItem = $catalogService->create([
...$request->validated(),
'tenant_code' => $tenant->codigo,
]);
return CatalogItemResource::make($catalogItem)
->response()
->setStatusCode(201);
}
private function userId(Request $request): ?int
{
$userId = $request->user()?->getAuthIdentifier() ?? Auth::guard('sanctum')->id();
return $userId === null ? null : (int) $userId;
return response()->json(CatalogFeaturedGroupResource::collection($featuredGroups)->resolve());
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Requests\StoreCategoryRequest;
use App\Domains\Catalog\Requests\UpdateCategoryRequest;
use App\Domains\Catalog\Resources\CategoryResource;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CategoryController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
return CategoryResource::collection(
Category::query()
->where(function ($query) use ($tenant): void {
$query
->where('tenant_code', $tenant->codigo)
->orWhereNull('tenant_code');
})
->with(['parent', 'subCategories', 'tenant'])
->orderByDesc('id')
->get()
)->response();
}
public function store(StoreCategoryRequest $request, Tenant $tenant): JsonResponse
{
$category = Category::query()->create([
...$request->validated(),
'tenant_code' => $tenant->codigo,
]);
return CategoryResource::make($category->load(['parent', 'subCategories', 'tenant']))
->response()
->setStatusCode(201);
}
public function show(Tenant $tenant, Category $categoria): CategoryResource
{
$categoria = $this->resolveScopedCategory($tenant, $categoria);
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
}
public function update(UpdateCategoryRequest $request, Tenant $tenant, Category $categoria): CategoryResource
{
$categoria = $this->resolveScopedCategory($tenant, $categoria);
$this->ensureCategoryIsMutable($categoria);
$categoria->update($request->validated());
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
}
public function destroy(Tenant $tenant, Category $categoria): Response
{
$categoria = $this->resolveScopedCategory($tenant, $categoria);
$this->ensureCategoryIsMutable($categoria);
$categoria->delete();
return response()->noContent();
}
protected function resolveScopedCategory(Tenant $tenant, Category $category): Category
{
if ($category->tenant_code !== null && $category->tenant_code !== $tenant->codigo) {
throw new NotFoundHttpException('Category not found for tenant.');
}
return $category;
}
protected function ensureCategoryIsMutable(Category $category): void
{
if ($category->isGlobal()) {
throw new AccessDeniedHttpException('Global categories are read-only.');
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Requests\StoreFeaturedGroupRequest;
use App\Domains\Catalog\Requests\UpdateFeaturedGroupRequest;
use App\Domains\Catalog\Resources\FeaturedGroupResource;
use App\Domains\Catalog\Services\FeaturedGroupService;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Routing\Controller;
use Illuminate\Http\JsonResponse;
class FeaturedGroupController extends Controller
{
public function __construct(
private readonly FeaturedGroupService $featuredGroupService
) {}
public function index(string $tenant): AnonymousResourceCollection
{
$groups = FeaturedGroup::where('tenant_codigo', $tenant)->get();
return FeaturedGroupResource::collection($groups);
}
public function store(StoreFeaturedGroupRequest $request, string $tenant): FeaturedGroupResource
{
$group = $this->featuredGroupService->createGroup($tenant, $request->validated());
return new FeaturedGroupResource($group);
}
public function show(string $tenant, FeaturedGroup $featuredGroup): FeaturedGroupResource
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
return new FeaturedGroupResource($featuredGroup->load('featuredVariants'));
}
public function update(UpdateFeaturedGroupRequest $request, string $tenant, FeaturedGroup $featuredGroup): FeaturedGroupResource
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
$group = $this->featuredGroupService->updateGroup($featuredGroup, $request->validated());
return new FeaturedGroupResource($group);
}
public function destroy(string $tenant, FeaturedGroup $featuredGroup): JsonResponse
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
$this->featuredGroupService->deleteGroup($featuredGroup);
return response()->json(null, 204);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\FeaturedVariant;
use App\Domains\Catalog\Requests\StoreFeaturedVariantRequest;
use App\Domains\Catalog\Requests\UpdateFeaturedVariantRequest;
use App\Domains\Catalog\Resources\FeaturedVariantResource;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Routing\Controller;
use Illuminate\Http\JsonResponse;
class FeaturedVariantController extends Controller
{
public function index(string $tenant, FeaturedGroup $featuredGroup): AnonymousResourceCollection
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
return FeaturedVariantResource::collection($featuredGroup->featuredVariants);
}
public function store(StoreFeaturedVariantRequest $request, string $tenant, FeaturedGroup $featuredGroup): FeaturedVariantResource
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
$variant = $featuredGroup->featuredVariants()->create($request->validated());
return new FeaturedVariantResource($variant);
}
public function update(UpdateFeaturedVariantRequest $request, string $tenant, FeaturedGroup $featuredGroup, FeaturedVariant $featuredVariant): FeaturedVariantResource
{
abort_if($featuredGroup->tenant_codigo !== $tenant || $featuredVariant->featured_group_id !== $featuredGroup->id, 404);
$featuredVariant->update($request->validated());
return new FeaturedVariantResource($featuredVariant);
}
public function destroy(string $tenant, FeaturedGroup $featuredGroup, FeaturedVariant $featuredVariant): JsonResponse
{
abort_if($featuredGroup->tenant_codigo !== $tenant || $featuredVariant->featured_group_id !== $featuredGroup->id, 404);
$featuredVariant->delete();
return response()->json(null, 204);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Requests\ProductDetailRequest;
use App\Domains\Catalog\Requests\StoreProductRequest;
use App\Domains\Catalog\Requests\UpdateProductRequest;
use App\Domains\Catalog\Resources\ProductResource;
use App\Domains\Catalog\Services\ProductService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ProductController extends Controller
{
public function index(Tenant $tenant, ProductService $productService): JsonResponse
{
return ProductResource::collection(
$productService->getProductos($tenant)
)->response();
}
public function store(StoreProductRequest $request, Tenant $tenant, ProductService $productService): JsonResponse
{
$product = $productService->create($tenant, $request->validated());
return ProductResource::make($product)->response()->setStatusCode(201);
}
public function show(ProductDetailRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$variantId = $request->query('variant_id');
$variantId = $variantId !== null ? (int) $variantId : null;
$producto = $productService->getProductDetail($tenant, $producto, $variantId);
return ProductResource::make($producto);
}
public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$producto = $productService->update($producto, $request->validated());
return ProductResource::make($producto);
}
public function destroy(Tenant $tenant, Product $producto, ProductService $productService): Response
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$productService->delete($producto);
return response()->noContent();
}
protected function resolveScopedProduct(Tenant $tenant, Product $product): Product
{
if ($product->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Product not found.');
}
return $product;
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Requests\StoreProductVariantRequest;
use App\Domains\Catalog\Requests\UpdateProductVariantRequest;
use App\Domains\Catalog\Resources\ProductVariantResource;
use App\Domains\Catalog\Services\ProductService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ProductVariantController extends Controller
{
public function index(Tenant $tenant, Product $producto): JsonResponse
{
$query = ProductVariant::query()
->where('producto_id', $producto->id)
->with(['product', 'definitions.productAttribute.attribute.options', 'attachments'])
->latest();
return ProductVariantResource::collection($query->paginateFromRequest())->response();
}
public function store(StoreProductVariantRequest $request, Tenant $tenant, Product $producto, ProductService $productService): JsonResponse
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$variant = $productService->createVariant($producto, $request->validated());
return ProductVariantResource::make($variant)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, Product $producto, ProductVariant $productVariant): ProductVariantResource
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$productVariant = $this->resolveScopedVariant($producto, $productVariant);
return ProductVariantResource::make($productVariant->load([
'attachments' => fn ($query) => $query->orderBy('attachments.id'),
'definitions.productAttribute.attribute.options',
'product.attachments' => fn ($query) => $query->orderBy('attachments.id'),
]));
}
public function update(UpdateProductVariantRequest $request, Tenant $tenant, Product $producto, ProductVariant $productVariant, ProductService $productService): ProductVariantResource
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$productVariant = $this->resolveScopedVariant($producto, $productVariant);
$productVariant = $productService->updateVariant($productVariant, $request->validated());
return ProductVariantResource::make($productVariant);
}
public function destroy(Tenant $tenant, Product $producto, ProductVariant $productVariant, ProductService $productService): Response
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$productVariant = $this->resolveScopedVariant($producto, $productVariant);
$productService->deleteVariant($productVariant);
return response()->noContent();
}
protected function resolveScopedProduct(Tenant $tenant, Product $product): Product
{
if ($product->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Product not found for tenant.');
}
return $product;
}
protected function resolveScopedVariant(Product $product, ProductVariant $variant): ProductVariant
{
if ($variant->producto_id !== $product->id) {
throw new NotFoundHttpException('Product variant not found for product.');
}
return $variant;
}
}

View File

@@ -1,10 +0,0 @@
<?php
namespace App\Domains\Catalog\Enums;
enum AvailabilityEffect: string
{
case Hide = 'hide';
case Restrict = 'restrict';
case Notice = 'notice';
}

View File

@@ -1,11 +0,0 @@
<?php
namespace App\Domains\Catalog\Enums;
enum CatalogAction: string
{
case SelectVariant = 'select_variant';
case ChangeQuantity = 'change_quantity';
case AddToCart = 'add_to_cart';
case BuyNow = 'buy_now';
}

View File

@@ -1,15 +0,0 @@
<?php
namespace App\Domains\Catalog\Enums;
enum CatalogItemType: string
{
case Standard = 'standard';
case Bundle = 'bundle';
/** @return array<int, string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Domains\Catalog\Enums;
enum FeaturedGroupSource: string
{
case Manual = 'manual';
case Category = 'category';
case All = 'all';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -1,20 +0,0 @@
<?php
namespace App\Domains\Catalog\Enums;
enum GroupLayout: string
{
case Paginated = 'paginated';
case Simple = 'simple';
case SimpleVertical = 'simple_vertical';
case Carousel = 'carousel';
case Single = 'single';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -1,17 +0,0 @@
<?php
namespace App\Domains\Catalog\Enums;
enum InventoryPolicy: string
{
case Tracked = 'tracked';
case Unlimited = 'unlimited';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Domains\Catalog\Enums;
enum InventorySubject: string
{
case Product = 'product';
case Seat = 'seat';
case Ticket = 'ticket';
/** @return array<int, string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

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