Compare commits

..

26 Commits

Author SHA1 Message Date
2ce21799c7 feat(attachments): enhance AttachmentService to support base64 file uploads and refactor file handling logic 2026-06-25 16:09:46 -03:00
17799122e2 feat(attachments): update filename generation in AttachmentService to include file extension 2026-06-25 15:43:31 -03:00
e4448d810e feat(cart): remove carritos and carrito_items migration files 2026-06-25 15:34:04 -03:00
7994fe0c96 feat(attachments): refactor attachment management to use attachable pivot table and update related models and tests 2026-06-25 15:30:11 -03:00
ee59236d55 feat(attachments): ensure 'key' is set as UUID on attachment creation and update tests for key validation 2026-06-25 15:11:31 -03:00
aec92d5346 feat(attachments): implement AttachmentService for managing uploads and deletions, add AttachmentStorageException for error handling 2026-06-25 15:02:52 -03:00
6db98ddc1a feat(attachments): add HasAttachments trait to manage attachment relationships 2026-06-25 14:47:21 -03:00
56b87f0c12 feat(attachments): add AttachmentType enum and update Attachment model to use it 2026-06-25 14:44:44 -03:00
9c5a29d933 feat(attachments): add 'key' field to Attachment model and migration, generate UUID on creation 2026-06-25 14:31:03 -03:00
049484dee5 feat(storage): implement S3 file upload and temporary URL generation functionality 2026-06-25 14:23:34 -03:00
3360e81e96 feat(aws): update AWS configuration in .env.example and filesystems.php for enhanced settings 2026-06-25 14:20:30 -03:00
93aead8dbf feat(dependencies): add league/flysystem-aws-s3-v3 and aws/aws-sdk-php to composer files 2026-06-25 12:18:46 -03:00
740566b02d refactor(attachments): remove is_public field from Attachment model and migration 2026-06-25 12:16:51 -03:00
b874a45724 feat(attachments): create Attachment model and migration for file attachments 2026-06-25 12:07:52 -03:00
492c6c4ac2 feat(cart): implement cart management with add, update, and remove item functionalities 2026-06-25 10:24:45 -03:00
a5b1539a80 feat(cart): create carritos and carrito_items tables with relationships 2026-06-25 10:08:29 -03:00
9b82800921 feat(cart): add Cart and CartItem models with item management methods 2026-06-25 10:03:17 -03:00
0e2ffc1c74 feat(catalog): add product variant controller, requests and resources 2026-06-24 15:29:01 -03:00
1907178e52 refactor(catalog): nest catalog routes under tenant
Move Catalog endpoints under /api/tenants/{tenant:codigo} and derive tenant
context from the route instead of request payload fields.

- scope marcas, categorias, productos, and product-attributes by tenant
- enforce tenant ownership in catalog controllers
- remove tenant_codigo and tenant_code from Catalog request validation
- update product attribute tests to use tenant-scoped routes
2026-06-24 15:16:38 -03:00
c6e5177df1 feat(catalog): add tenant-scoped product attributes and variant definitions 2026-06-24 15:08:13 -03:00
689d115a8f feat(tenant): reintroduce tenant-specific props and instance values
Restore props support only for the Tenant domain using a tenant-scoped model
instead of the removed generic Prop/Propable implementation.

Add tenant prop definitions and instance values:
- add TenantProp and TenantPropValue models
- add tenant-only data type enum and prop validation rules
- add migrations for tenant_props and tenant_prop_values tables
- persist prop values by tenant_codigo + tenant_prop_codigo

Expose tenant prop management through the Tenant domain:
- add TenantPropController with CRUD endpoints
- add Store/Update request classes for tenant prop definitions
- register tenant-props routes

Re-enable tenant prop syncing and serialization:
- restore createWithProps/updateWithProps flows in Tenant
- validate props on tenant create/update requests
- include collapsed props in TenantResource
- keep the implementation scoped to Tenant without reintroducing polymorphic props

Add coverage for the new tenant prop behavior:
- update bootstrap tenant feature test to assert prop values
- add feature tests for tenant prop creation and validation
2026-06-24 14:50:58 -03:00
75129b7552 remove props 2026-06-24 14:43:10 -03:00
8e343ea9c3 Add ProductVariant model and migration for productos_variantes table 2026-06-24 11:41:42 -03:00
696010a70e Remove unused 'props' unset operation in store and update methods across Brand, Category, Product, and Tenant controllers 2026-06-24 11:13:18 -03:00
e4139d48aa Refactor request validation to use model-specific prop synchronization and add migrations for categories and brands 2026-06-24 11:02:35 -03:00
8d840d94a0 Update StoreCategoryRequest to require tenant_code only when categoria_id is present 2026-06-24 10:43:23 -03:00
99 changed files with 3903 additions and 874 deletions

View File

@@ -56,10 +56,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_USE_PATH_STYLE_ENDPOINT=
AWS_HTTP_VERIFY=
VITE_APP_NAME="${APP_NAME}"

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Domains\Attachable\Enums;
enum AttachmentType: string
{
case Image = 'image';
case Video = 'video';
case Pdf = 'pdf';
case Document = 'document';
case Audio = 'audio';
case Other = 'other';
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Attachable\Exceptions;
use RuntimeException;
class AttachmentStorageException extends RuntimeException
{
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Domains\Attachable\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Fillable([
'attachable_type',
'attachable_id',
'attachment_id',
])]
class AttachableAttachment extends Model
{
protected $table = 'attachable_attachments';
public $timestamps = false;
protected function casts(): array
{
return [
'attachable_id' => 'integer',
'attachment_id' => 'integer',
];
}
/**
* @return MorphTo<Model, $this>
*/
public function attachable(): MorphTo
{
return $this->morphTo();
}
/**
* @return BelongsTo<Attachment, $this>
*/
public function attachment(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'attachment_id');
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Domains\Attachable\Models;
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\Support\Str;
#[Fillable([
'key',
'path',
'filename',
'type',
'mime_type',
'extension',
'size',
])]
class Attachment extends Model
{
use HasFactory;
protected $table = 'attachments';
protected static function booted(): void
{
static::creating(function (self $attachment): void {
if (! $attachment->key) {
$attachment->key = (string) Str::uuid();
}
});
}
protected function casts(): array
{
return [
'type' => AttachmentType::class,
'size' => 'integer',
];
}
/**
* @return HasMany<AttachableAttachment, $this>
*/
public function attachables(): HasMany
{
return $this->hasMany(AttachableAttachment::class, 'attachment_id');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Attachable\Models\Concerns;
use App\Domains\Attachable\Models\Attachment;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
trait HasAttachments
{
/**
* @return MorphToMany<Attachment, Model, $this>
*/
public function attachments(): MorphToMany
{
return $this->morphToMany(
Attachment::class,
'attachable',
'attachable_attachments',
'attachable_id',
'attachment_id',
);
}
}

View File

@@ -0,0 +1,253 @@
<?php
namespace App\Domains\Attachable\Services;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Exceptions\AttachmentStorageException;
use App\Domains\Attachable\Models\Attachment;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\Mime\MimeTypes;
use Throwable;
class AttachmentService
{
public function store(
UploadedFile|string $file,
string $path,
): Attachment {
$normalizedPath = $this->normalizeDirectory($path);
if ($normalizedPath === '') {
throw new AttachmentStorageException('The attachment path cannot be empty.');
}
$key = (string) Str::uuid();
$fileData = $this->resolveFileData($file, $key);
$storedPath = $this->storeFile(
$fileData,
$normalizedPath,
$this->buildStoredFilename($key, $fileData['extension']),
);
if (! is_string($storedPath) || $storedPath === '') {
throw new AttachmentStorageException('No se pudo subir el archivo al disco s3.');
}
try {
/** @var Attachment $attachment */
$attachment = Attachment::query()->create([
'key' => $key,
'path' => $storedPath,
'filename' => $this->resolveFilename($file, $key),
'type' => $this->resolveAttachmentType($fileData['mime_type']),
'mime_type' => $fileData['mime_type'],
'extension' => $fileData['extension'],
'size' => $fileData['size'],
]);
return $attachment;
} catch (Throwable $throwable) {
Storage::disk('s3')->delete($storedPath);
throw $throwable;
}
}
public function delete(Attachment $attachment): void
{
$deleted = Storage::disk('s3')->delete($attachment->path);
if (! $deleted) {
throw new AttachmentStorageException('No se pudo eliminar el archivo del disco s3.');
}
$attachment->delete();
}
protected function normalizeDirectory(string $path): string
{
return trim($path, '/');
}
protected function resolveFilename(UploadedFile|string $file, string $key): string
{
if ($file instanceof UploadedFile) {
$originalName = trim($file->getClientOriginalName());
if ($originalName !== '') {
return $originalName;
}
}
return $key;
}
/**
* @return array{contents: string|null, extension: string, file: UploadedFile|null, mime_type: string, size: int}
*/
protected function resolveFileData(UploadedFile|string $file, string $filename): array
{
if ($file instanceof UploadedFile) {
return $this->fileDataFromUploadedFile($file);
}
return $this->fileDataFromBase64($file, $filename);
}
/**
* @return array{contents: string|null, extension: string, file: UploadedFile, mime_type: string, size: int}
*/
protected function fileDataFromUploadedFile(UploadedFile $file): array
{
$mimeType = strtolower($file->getClientMimeType() ?? $file->getMimeType() ?? 'application/octet-stream');
return [
'contents' => null,
'extension' => strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: ''),
'file' => $file,
'mime_type' => $mimeType,
'size' => $file->getSize() ?? 0,
];
}
/**
* @return array{contents: string, extension: string, file: null, mime_type: string, size: int}
*/
protected function fileDataFromBase64(string $file, string $filename): array
{
['data' => $contents, 'mime_type' => $declaredMimeType] = $this->decodeBase64File($file);
$mimeType = $this->detectMimeType($contents, $declaredMimeType);
return [
'contents' => $contents,
'extension' => $this->resolveExtension($filename, $mimeType),
'file' => null,
'mime_type' => $mimeType,
'size' => strlen($contents),
];
}
/**
* @return array{data: string, mime_type: string|null}
*/
protected function decodeBase64File(string $file): array
{
$payload = trim($file);
if ($payload === '') {
throw new AttachmentStorageException('The base64 attachment content cannot be empty.');
}
$declaredMimeType = null;
if (preg_match('/^data:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/s', $payload, $matches) === 1) {
$declaredMimeType = strtolower($matches['mime']);
$payload = $matches['data'];
}
$decoded = base64_decode(preg_replace('/\s+/', '', $payload), true);
if ($decoded === false || $decoded === '') {
throw new AttachmentStorageException('The attachment base64 payload is invalid.');
}
return [
'data' => $decoded,
'mime_type' => $declaredMimeType,
];
}
protected function detectMimeType(string $contents, ?string $fallback = null): string
{
if (is_string($fallback) && $fallback !== '') {
return strtolower($fallback);
}
$detectedMimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($contents);
if (is_string($detectedMimeType) && $detectedMimeType !== '') {
return strtolower($detectedMimeType);
}
return strtolower($fallback ?? 'application/octet-stream');
}
protected function resolveExtension(string $filename, string $mimeType): string
{
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if ($extension !== '') {
return $extension;
}
return strtolower(MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? '');
}
/**
* @param array{contents: string|null, extension: string, file: UploadedFile|null, mime_type: string, size: int} $fileData
*/
protected function storeFile(array $fileData, string $directory, string $storedFilename): string
{
if ($fileData['file'] instanceof UploadedFile) {
return Storage::disk('s3')->putFileAs($directory, $fileData['file'], $storedFilename);
}
$storedPath = $directory !== ''
? $directory.'/'.$storedFilename
: $storedFilename;
$stored = Storage::disk('s3')->put($storedPath, $fileData['contents'] ?? '');
if (! $stored) {
return '';
}
return $storedPath;
}
protected function resolveAttachmentType(string $mimeType): AttachmentType
{
$mimeType = strtolower($mimeType);
if (str_starts_with($mimeType, 'image/')) {
return AttachmentType::Image;
}
if (str_starts_with($mimeType, 'video/')) {
return AttachmentType::Video;
}
if ($mimeType === 'application/pdf') {
return AttachmentType::Pdf;
}
if (str_starts_with($mimeType, 'audio/')) {
return AttachmentType::Audio;
}
if (
str_starts_with($mimeType, 'text/')
|| str_contains($mimeType, 'document')
|| str_contains($mimeType, 'word')
|| str_contains($mimeType, 'excel')
|| str_contains($mimeType, 'spreadsheet')
|| str_contains($mimeType, 'presentation')
|| str_contains($mimeType, 'officedocument')
) {
return AttachmentType::Document;
}
return AttachmentType::Other;
}
protected function buildStoredFilename(string $key, string $extension): string
{
if ($extension === '') {
return $key;
}
return $key.'.'.$extension;
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Domains\Cart\Controllers;
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;
use Illuminate\Http\Request;
class CartController extends Controller
{
public function __construct(
protected CartService $cartService,
) {
}
public function show(Request $request, Tenant $tenant): CartResource
{
return CartResource::make($this->cartService->show($tenant, $request));
}
public function addItem(AddCartItemRequest $request, Tenant $tenant): JsonResponse
{
$result = $this->cartService->addItem(
$tenant,
$request,
(int) $request->validated('product_variant_id'),
(int) $request->validated('cantidad'),
);
$response = CartResource::make($result['cart'])->response();
if ($result['guest_token'] !== null) {
$response->withCookie($this->cartService->makeGuestTokenCookie($result['guest_token']));
}
return $response;
}
public function updateItemQuantity(
UpdateCartItemQuantityRequest $request,
Tenant $tenant,
ProductVariant $productVariant,
): CartResource {
return CartResource::make(
$this->cartService->updateItemQuantity(
$tenant,
$request,
$productVariant->getKey(),
(int) $request->validated('cantidad'),
)
);
}
public function removeItem(Request $request, Tenant $tenant, ProductVariant $productVariant): CartResource
{
return CartResource::make(
$this->cartService->removeItem($tenant, $request, $productVariant->getKey())
);
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace App\Domains\Cart\Models;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
#[Fillable([
'tenant_codigo',
'user_id',
'guest_token',
'status',
])]
class Cart extends Model
{
use HasFactory;
protected $table = 'carritos';
protected function casts(): array
{
return [
'user_id' => 'integer',
];
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return HasMany<CartItem, $this>
*/
public function items(): HasMany
{
return $this->hasMany(CartItem::class, 'cart_id');
}
public function addItem(int $productVariantId, int $quantity): CartItem
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => 'La cantidad debe ser mayor a cero.',
]);
}
return DB::transaction(function () use ($productVariantId, $quantity): CartItem {
$variant = $this->resolveScopedVariant($productVariantId, true);
if ($variant->stock < $quantity) {
throw ValidationException::withMessages([
'cantidad' => 'Stock insuficiente para la variante solicitada.',
]);
}
/** @var CartItem|null $item */
$item = $this->items()
->where('producto_variante_id', $variant->getKey())
->lockForUpdate()
->first();
if ($item === null) {
$item = $this->items()->create([
'producto_variante_id' => $variant->getKey(),
'cantidad' => $quantity,
]);
} else {
$item->cantidad += $quantity;
$item->save();
}
$variant->decrement('stock', $quantity);
return $item->fresh();
});
}
public function updateItem(int $productVariantId, int $quantity): CartItem
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => 'La cantidad debe ser mayor a cero.',
]);
}
return DB::transaction(function () use ($productVariantId, $quantity): CartItem {
/** @var CartItem $item */
$item = $this->items()
->where('producto_variante_id', $productVariantId)
->lockForUpdate()
->firstOrFail();
$variant = $this->resolveScopedVariant($productVariantId, true);
$delta = $quantity - $item->cantidad;
if ($delta > 0 && $variant->stock < $delta) {
throw ValidationException::withMessages([
'cantidad' => 'Stock insuficiente para actualizar la cantidad solicitada.',
]);
}
$item->cantidad = $quantity;
$item->save();
if ($delta > 0) {
$variant->decrement('stock', $delta);
}
if ($delta < 0) {
$variant->increment('stock', abs($delta));
}
return $item->fresh();
});
}
public function removeItem(int $productVariantId): void
{
DB::transaction(function () use ($productVariantId): void {
/** @var CartItem $item */
$item = $this->items()
->where('producto_variante_id', $productVariantId)
->lockForUpdate()
->firstOrFail();
$variant = $this->resolveScopedVariant($productVariantId, true);
$variant->increment('stock', $item->cantidad);
$item->delete();
});
}
protected function resolveScopedVariant(int $productVariantId, bool $lockForUpdate = false): ProductVariant
{
$query = ProductVariant::query()
->whereKey($productVariantId)
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo));
if ($lockForUpdate) {
$query->lockForUpdate();
}
/** @var ProductVariant|null $variant */
$variant = $query->first();
if ($variant === null) {
throw new NotFoundHttpException('Product variant not found for tenant.');
}
return $variant;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Domains\Cart\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([
'cart_id',
'producto_variante_id',
'cantidad',
])]
class CartItem extends Model
{
use HasFactory;
protected $table = 'carrito_items';
protected function casts(): array
{
return [
'cart_id' => 'integer',
'producto_variante_id' => 'integer',
'cantidad' => 'integer',
];
}
/**
* @return BelongsTo<Cart, $this>
*/
public function cart(): BelongsTo
{
return $this->belongsTo(Cart::class, 'cart_id');
}
/**
* @return BelongsTo<ProductVariant, $this>
*/
public function variant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Cart\Requests;
use Illuminate\Foundation\Http\FormRequest;
class AddCartItemRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'product_variant_id' => ['required', 'integer'],
'cantidad' => ['required', 'integer', 'min:1'],
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Cart\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateCartItemQuantityRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'cantidad' => ['required', 'integer', 'min:1'],
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Domains\Cart\Resources;
use App\Domains\Catalog\Resources\ProductVariantDefinitionResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Cart\Models\CartItem
*/
class CartItemResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$variant = $this->variant;
$product = $variant?->product;
return [
'id' => $this->id,
'cantidad' => $this->cantidad,
'precio_unitario' => $this->formatMoney($variant?->precio),
'subtotal' => $this->formatMoney(($variant?->precio ?? 0) * $this->cantidad),
'product' => $product === null ? null : [
'id' => $product->id,
'nombre' => $product->nombre,
'slug' => $product->slug,
],
'variant' => $variant === null ? null : [
'id' => $variant->id,
'nombre' => $variant->nombre,
'slug' => $variant->slug,
'definitions' => ProductVariantDefinitionResource::collection($variant->definitions),
],
];
}
protected function formatMoney(float|int|string|null $amount): string
{
return number_format((float) ($amount ?? 0), 2, '.', '');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domains\Cart\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Cart\Models\Cart
*/
class CartResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$items = $this->resource->relationLoaded('items')
? $this->resource->getRelation('items')
: collect();
$subtotal = $items->reduce(
fn (float $carry, $item): float => $carry + ((float) ($item->variant?->precio ?? 0) * $item->cantidad),
0.0,
);
return [
'id' => $this->id,
'tenant_codigo' => $this->tenant_codigo,
'status' => $this->status ?? 'active',
'items' => CartItemResource::collection($items),
'subtotal' => $this->formatMoney($subtotal),
];
}
protected function formatMoney(float|int|string|null $amount): string
{
return number_format((float) ($amount ?? 0), 2, '.', '');
}
}

View File

@@ -0,0 +1,204 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Tenant\Models\Tenant;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CartService
{
public function show(Tenant $tenant, Request $request): Cart
{
$identity = $this->resolveIdentity($request);
if ($identity === null) {
return $this->makeEmptyCart($tenant);
}
$cart = $this->findCart($tenant, $identity);
if ($cart === null) {
return $this->makeEmptyCart($tenant);
}
return $this->loadCart($cart);
}
/**
* @return array{cart: Cart, guest_token: ?string}
*/
public function addItem(Tenant $tenant, Request $request, int $productVariantId, int $quantity): array
{
$resolvedIdentity = $this->resolveIdentity($request, true);
$identity = $resolvedIdentity['identity'];
$cart = $this->findOrCreateCart($tenant, $identity);
$cart->addItem($productVariantId, $quantity);
return [
'cart' => $this->loadCart($cart),
'guest_token' => $resolvedIdentity['generated_guest_token'],
];
}
public function updateItemQuantity(Tenant $tenant, Request $request, int $productVariantId, int $quantity): Cart
{
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->updateItem($productVariantId, $quantity);
return $this->loadCart($cart);
}
public function removeItem(Tenant $tenant, Request $request, int $productVariantId): Cart
{
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->removeItem($productVariantId);
return $this->loadCart($cart);
}
public function makeGuestTokenCookie(string $guestToken): Cookie
{
return cookie(
'guest_token',
$guestToken,
60 * 24 * 180,
'/',
null,
false,
true,
false,
'lax',
);
}
protected function makeEmptyCart(Tenant $tenant): Cart
{
$cart = new Cart([
'tenant_codigo' => $tenant->codigo,
'status' => 'active',
]);
$cart->setRelation('items', collect());
return $cart;
}
protected function loadCart(Cart $cart): Cart
{
return $cart->fresh()->load([
'items.variant.product',
'items.variant.definitions.attribute',
]);
}
/**
* @return array{identity: array{user_id: ?int, guest_token: ?string}, generated_guest_token: ?string}|null
*/
protected function resolveIdentity(Request $request, bool $generateGuestToken = false): ?array
{
$user = $request->user();
if ($user instanceof User) {
return [
'identity' => [
'user_id' => $user->getKey(),
'guest_token' => null,
],
'generated_guest_token' => null,
];
}
$guestToken = $request->cookie('guest_token');
if (is_string($guestToken) && $guestToken !== '') {
return [
'identity' => [
'user_id' => null,
'guest_token' => $guestToken,
],
'generated_guest_token' => null,
];
}
if (! $generateGuestToken) {
return null;
}
$generatedGuestToken = (string) Str::uuid();
return [
'identity' => [
'user_id' => null,
'guest_token' => $generatedGuestToken,
],
'generated_guest_token' => $generatedGuestToken,
];
}
/**
* @return array{user_id: ?int, guest_token: ?string}
*/
protected function requireIdentity(Request $request): array
{
$resolvedIdentity = $this->resolveIdentity($request);
if ($resolvedIdentity === null) {
throw new NotFoundHttpException('Cart not found.');
}
return $resolvedIdentity['identity'];
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function findCart(Tenant $tenant, array $identity): ?Cart
{
return Cart::query()
->where('tenant_codigo', $tenant->codigo)
->when(
$identity['user_id'] !== null,
fn ($query) => $query->where('user_id', $identity['user_id']),
fn ($query) => $query->where('guest_token', $identity['guest_token']),
)
->first();
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
{
$cart = $this->findCart($tenant, $identity);
if ($cart === null) {
throw new NotFoundHttpException('Cart not found.');
}
return $cart;
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
{
$attributes = ['tenant_codigo' => $tenant->codigo];
if ($identity['user_id'] !== null) {
$attributes['user_id'] = $identity['user_id'];
} else {
$attributes['guest_token'] = $identity['guest_token'];
}
return Cart::query()->firstOrCreate($attributes, ['status' => 'active']);
}
}

View File

@@ -0,0 +1,11 @@
<?php
use App\Domains\Cart\Controllers\CartController;
use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
Route::get('cart', [CartController::class, 'show']);
Route::post('cart/items', [CartController::class, 'addItem']);
Route::patch('cart/items/{productVariant}', [CartController::class, 'updateItemQuantity']);
Route::delete('cart/items/{productVariant}', [CartController::class, 'removeItem']);
});

View File

@@ -6,48 +6,60 @@ 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(): JsonResponse
public function index(Tenant $tenant): JsonResponse
{
return BrandResource::collection(Brand::query()->orderByDesc('id')->get())->response();
return BrandResource::collection(
Brand::query()->where('tenant_codigo', $tenant->codigo)->orderByDesc('id')->get()
)->response();
}
public function store(StoreBrandRequest $request): JsonResponse
public function store(StoreBrandRequest $request, Tenant $tenant): JsonResponse
{
$validated = $request->validated();
$props = $validated['props'] ?? [];
unset($validated['props']);
$brand = Brand::createWithProps($validated, $props);
$brand = Brand::query()->create([
...$request->validated(),
'tenant_codigo' => $tenant->codigo,
]);
return BrandResource::make($brand)->response()->setStatusCode(201);
}
public function show(Brand $marca): BrandResource
public function show(Tenant $tenant, Brand $marca): BrandResource
{
return BrandResource::make($marca);
}
public function update(UpdateBrandRequest $request, Brand $marca): BrandResource
{
$validated = $request->validated();
$props = $validated['props'] ?? [];
unset($validated['props']);
$marca = $marca->updateWithProps($validated, $props);
$marca = $this->resolveScopedBrand($tenant, $marca);
return BrandResource::make($marca);
}
public function destroy(Brand $marca): Response
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

@@ -6,52 +6,66 @@ 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\NotFoundHttpException;
class CategoryController extends Controller
{
public function index(): JsonResponse
public function index(Tenant $tenant): JsonResponse
{
return CategoryResource::collection(
Category::query()->with(['parent', 'subCategories','tenant'])->orderByDesc('id')->get()
Category::query()
->where('tenant_code', $tenant->codigo)
->with(['parent', 'subCategories', 'tenant'])
->orderByDesc('id')
->get()
)->response();
}
public function store(StoreCategoryRequest $request): JsonResponse
public function store(StoreCategoryRequest $request, Tenant $tenant): JsonResponse
{
$validated = $request->validated();
$props = $validated['props'] ?? [];
unset($validated['props']);
$category = Category::createWithProps($validated, $props);
$category = Category::query()->create([
...$request->validated(),
'tenant_code' => $tenant->codigo,
]);
return CategoryResource::make($category->load(['parent', 'subCategories', 'tenant']))
->response()
->setStatusCode(201);
}
public function show(Category $categoria): CategoryResource
public function show(Tenant $tenant, Category $categoria): CategoryResource
{
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
}
public function update(UpdateCategoryRequest $request, Category $categoria): CategoryResource
{
$validated = $request->validated();
$props = $validated['props'] ?? [];
unset($validated['props']);
$categoria = $categoria->updateWithProps($validated, $props);
$categoria = $this->resolveScopedCategory($tenant, $categoria);
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
}
public function destroy(Category $categoria): Response
public function update(UpdateCategoryRequest $request, Tenant $tenant, Category $categoria): CategoryResource
{
$categoria = $this->resolveScopedCategory($tenant, $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);
$categoria->delete();
return response()->noContent();
}
protected function resolveScopedCategory(Tenant $tenant, Category $category): Category
{
if ($category->tenant_code !== $tenant->codigo) {
throw new NotFoundHttpException('Category not found for tenant.');
}
return $category;
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\ProductAttribute;
use App\Domains\Catalog\Requests\StoreProductAttributeRequest;
use App\Domains\Catalog\Requests\UpdateProductAttributeRequest;
use App\Domains\Catalog\Resources\ProductAttributeResource;
use App\Domains\Catalog\Support\ProductAttributeType;
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 ProductAttributeController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
$query = ProductAttribute::query()
->where('tenant_codigo', $tenant->codigo)
->with('options')
->latest();
return ProductAttributeResource::collection($query->get())->response();
}
public function store(StoreProductAttributeRequest $request, Tenant $tenant): JsonResponse
{
$attribute = DB::transaction(function () use ($request, $tenant): ProductAttribute {
$validated = $request->validated();
$options = $validated['options'] ?? [];
unset($validated['options']);
$type = ProductAttributeType::from((string) $validated['type']);
if (! $type->supportsOptions()) {
$validated['metadata_schema'] = null;
$options = [];
}
/** @var ProductAttribute $attribute */
$attribute = ProductAttribute::query()->create([
...$validated,
'tenant_codigo' => $tenant->codigo,
]);
$attribute->options()->createMany($options);
return $attribute->load('options');
});
return ProductAttributeResource::make($attribute)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, ProductAttribute $productAttribute): ProductAttributeResource
{
$productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute);
return ProductAttributeResource::make($productAttribute->load('options'));
}
public function update(UpdateProductAttributeRequest $request, Tenant $tenant, ProductAttribute $productAttribute): ProductAttributeResource
{
$productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute);
$productAttribute = DB::transaction(function () use ($request, $productAttribute): ProductAttribute {
$validated = $request->validated();
$options = $validated['options'] ?? [];
unset($validated['options']);
$type = ProductAttributeType::from((string) $validated['type']);
if (! $type->supportsOptions()) {
$validated['metadata_schema'] = null;
$options = [];
}
$productAttribute->update($validated);
$productAttribute->options()->delete();
$productAttribute->options()->createMany($options);
return $productAttribute->load('options');
});
return ProductAttributeResource::make($productAttribute);
}
public function destroy(Tenant $tenant, ProductAttribute $productAttribute): Response
{
$productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute);
$productAttribute->delete();
return response()->noContent();
}
protected function resolveScopedAttribute(Tenant $tenant, ProductAttribute $attribute): ProductAttribute
{
if ($attribute->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Product attribute not found for tenant.');
}
return $attribute;
}
}

View File

@@ -6,48 +6,60 @@ use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Requests\StoreProductRequest;
use App\Domains\Catalog\Requests\UpdateProductRequest;
use App\Domains\Catalog\Resources\ProductResource;
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 ProductController extends Controller
{
public function index(): JsonResponse
public function index(Tenant $tenant): JsonResponse
{
return ProductResource::collection(Product::query()->latest()->get())->response();
return ProductResource::collection(
Product::query()->where('tenant_codigo', $tenant->codigo)->latest()->get()
)->response();
}
public function store(StoreProductRequest $request): JsonResponse
public function store(StoreProductRequest $request, Tenant $tenant): JsonResponse
{
$validated = $request->validated();
$props = $validated['props'] ?? [];
unset($validated['props']);
$product = Product::createWithProps($validated, $props);
$product = Product::query()->create([
...$request->validated(),
'tenant_codigo' => $tenant->codigo,
]);
return ProductResource::make($product)->response()->setStatusCode(201);
}
public function show(Product $producto): ProductResource
public function show(Tenant $tenant, Product $producto): ProductResource
{
return ProductResource::make($producto);
}
public function update(UpdateProductRequest $request, Product $producto): ProductResource
{
$validated = $request->validated();
$props = $validated['props'] ?? [];
unset($validated['props']);
$producto = $producto->updateWithProps($validated, $props);
$producto = $this->resolveScopedProduct($tenant, $producto);
return ProductResource::make($producto);
}
public function destroy(Product $producto): Response
public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto): ProductResource
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$producto->update($request->validated());
return ProductResource::make($producto);
}
public function destroy(Tenant $tenant, Product $producto): Response
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$producto->delete();
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;
}
}

View File

@@ -0,0 +1,109 @@
<?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\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 ProductVariantController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
$query = ProductVariant::query()
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo))
->with(['product', 'definitions.attribute'])
->latest();
return ProductVariantResource::collection($query->get())->response();
}
public function store(StoreProductVariantRequest $request, Tenant $tenant): JsonResponse
{
$validated = $request->validated();
$variant = DB::transaction(function () use ($request, $tenant): ProductVariant {
$definitions = $validated['definitions'] ?? [];
unset($validated['definitions']);
$product = $this->resolveTenantProduct($tenant, (int) $validated['producto_id']);
/** @var ProductVariant $variant */
$variant = $product->variants()->create($validated);
$variant->definitions()->createMany($definitions);
return $variant->load(['product', 'definitions.attribute']);
});
return ProductVariantResource::make($variant)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, ProductVariant $productVariant): ProductVariantResource
{
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
return ProductVariantResource::make($productVariant->load(['product', 'definitions.attribute']));
}
public function update(UpdateProductVariantRequest $request, Tenant $tenant, ProductVariant $productVariant): ProductVariantResource
{
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
$productVariant = DB::transaction(function () use ($request, $tenant, $productVariant): ProductVariant {
$validated = $request->validated();
$definitions = $validated['definitions'] ?? [];
unset($validated['definitions']);
$this->resolveTenantProduct($tenant, (int) $validated['producto_id']);
$productVariant->update($validated);
$productVariant->definitions()->delete();
$productVariant->definitions()->createMany($definitions);
return $productVariant->load(['product', 'definitions.attribute']);
});
return ProductVariantResource::make($productVariant);
}
public function destroy(Tenant $tenant, ProductVariant $productVariant): Response
{
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
$productVariant->delete();
return response()->noContent();
}
protected function resolveScopedVariant(Tenant $tenant, ProductVariant $variant): ProductVariant
{
$variant->loadMissing('product');
if ($variant->product === null || $variant->product->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Product variant not found for tenant.');
}
return $variant;
}
protected function resolveTenantProduct(Tenant $tenant, int $productId): Product
{
$product = Product::query()
->whereKey($productId)
->where('tenant_codigo', $tenant->codigo)
->first();
if ($product === null) {
throw new NotFoundHttpException('Product not found for tenant.');
}
return $product;
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Prop\Concerns\Propable;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -17,7 +16,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Brand extends Model
{
use HasFactory;
use Propable;
protected $table = 'brands';

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Prop\Concerns\Propable;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -18,7 +17,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
use HasFactory;
use Propable;
protected $table = 'categorias';

View File

@@ -2,12 +2,12 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Prop\Concerns\Propable;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_codigo',
@@ -20,7 +20,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Product extends Model
{
use HasFactory;
use Propable;
protected $table = 'productos';
@@ -39,4 +38,12 @@ class Product extends Model
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return HasMany<ProductVariant, $this>
*/
public function variants(): HasMany
{
return $this->hasMany(ProductVariant::class, 'producto_id');
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Catalog\Support\ProductAttributeType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_codigo',
'codigo',
'nombre',
'is_required',
'metadata_schema',
'type',
])]
class ProductAttribute extends Model
{
use HasFactory;
protected $table = 'productos_attributes';
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_required' => 'boolean',
'metadata_schema' => 'array',
'type' => ProductAttributeType::class,
];
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return HasMany<ProductAttributeOption, $this>
*/
public function options(): HasMany
{
return $this->hasMany(ProductAttributeOption::class, 'attribute_id')->orderBy('sort_order');
}
/**
* @return HasMany<ProductVariantDefinition, $this>
*/
public function variantDefinitions(): HasMany
{
return $this->hasMany(ProductVariantDefinition::class, 'attribute_id');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'attribute_id',
'label',
'sort_order',
'metadata',
])]
class ProductAttributeOption extends Model
{
use HasFactory;
protected $table = 'attribute_options';
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'sort_order' => 'integer',
'metadata' => 'array',
];
}
/**
* @return BelongsTo<ProductAttribute, $this>
*/
public function attribute(): BelongsTo
{
return $this->belongsTo(ProductAttribute::class, 'attribute_id');
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'producto_id',
'slug',
'nombre',
'stock',
'descripcion',
'precio',
])]
class ProductVariant extends Model
{
use HasFactory;
protected $table = 'productos_variantes';
protected function casts(): array
{
return [
'producto_id' => 'integer',
'stock' => 'integer',
'precio' => 'decimal:2',
];
}
/**
* @return BelongsTo<Product, $this>
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class, 'producto_id');
}
/**
* @return HasMany<ProductVariantDefinition, $this>
*/
public function definitions(): HasMany
{
return $this->hasMany(ProductVariantDefinition::class, 'producto_variante_id');
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'producto_variante_id',
'attribute_id',
'value',
])]
class ProductVariantDefinition extends Model
{
use HasFactory;
protected $table = 'productos_variantes_definiciones';
/**
* @return BelongsTo<ProductVariant, $this>
*/
public function variant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
}
/**
* @return BelongsTo<ProductAttribute, $this>
*/
public function attribute(): BelongsTo
{
return $this->belongsTo(ProductAttribute::class, 'attribute_id');
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Catalog\Requests;
use App\Domains\Prop\Support\PropRules;
use Illuminate\Foundation\Http\FormRequest;
class StoreBrandRequest extends FormRequest
@@ -18,10 +17,8 @@ class StoreBrandRequest extends FormRequest
public function rules(): array
{
return [
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
...PropRules::sync(),
];
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Catalog\Requests;
use App\Domains\Prop\Support\PropRules;
use Illuminate\Foundation\Http\FormRequest;
class StoreCategoryRequest extends FormRequest
@@ -18,10 +17,8 @@ class StoreCategoryRequest extends FormRequest
public function rules(): array
{
return [
'tenant_code' => ['required', 'string', 'exists:tenants,codigo'],
'categoria_id' => ['nullable', 'integer', 'exists:categorias,id'],
'nombre' => ['required', 'string', 'max:255'],
...PropRules::sync(),
];
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Support\ProductAttributeType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class StoreProductAttributeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'codigo' => [
'required',
'string',
'max:255',
Rule::unique('productos_attributes', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'nombre' => ['required', 'string', 'max:255'],
'is_required' => ['sometimes', 'boolean'],
'metadata_schema' => ['nullable', 'array'],
'type' => ['required', Rule::enum(ProductAttributeType::class)],
'options' => [
Rule::requiredIf(fn (): bool => in_array($this->input('type'), [
ProductAttributeType::Select->value,
ProductAttributeType::Multiselect->value,
], true)),
Rule::prohibitedIf(fn (): bool => ! in_array($this->input('type'), [
ProductAttributeType::Select->value,
ProductAttributeType::Multiselect->value,
], true)),
'array',
],
'options.*.label' => ['required', 'string', 'max:255'],
'options.*.sort_order' => ['sometimes', 'integer'],
'options.*.metadata' => ['nullable', 'array'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$type = $this->input('type');
$supportsOptions = in_array($type, [ProductAttributeType::Select->value, ProductAttributeType::Multiselect->value], true);
if (! $supportsOptions && $this->filled('metadata_schema')) {
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
}
if (! $supportsOptions && $this->filled('options')) {
$validator->errors()->add('options', 'Options are only allowed for select and multiselect attributes.');
}
});
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Catalog\Requests;
use App\Domains\Prop\Support\PropRules;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -19,13 +18,11 @@ class StoreProductRequest extends FormRequest
public function rules(): array
{
return [
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
'categoria_id' => ['required', 'integer'],
'slug' => ['required', 'string', 'max:255', Rule::unique('productos', 'slug')],
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
...PropRules::sync(),
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProductVariantRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'producto_id' => [
'required',
'integer',
Rule::exists('productos', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'slug' => [
'nullable',
'string',
'max:255',
Rule::unique('productos_variantes', 'slug')->where(
fn ($query) => $query->where('producto_id', $this->input('producto_id'))
),
],
'nombre' => ['nullable', 'string', 'max:255'],
'stock' => ['sometimes', 'integer', 'min:0'],
'descripcion' => ['nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'definitions' => ['sometimes', 'array'],
'definitions.*.attribute_id' => [
'required',
'integer',
'distinct',
Rule::exists('productos_attributes', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'definitions.*.value' => ['nullable', 'string'],
];
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Catalog\Requests;
use App\Domains\Prop\Support\PropRules;
use Illuminate\Foundation\Http\FormRequest;
class UpdateBrandRequest extends FormRequest
@@ -18,10 +17,8 @@ class UpdateBrandRequest extends FormRequest
public function rules(): array
{
return [
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
...PropRules::sync(),
];
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Models\Category;
use App\Domains\Prop\Support\PropRules;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -23,7 +22,6 @@ class UpdateCategoryRequest extends FormRequest
$category = $this->route('categoria');
return [
'tenant_code' => ['required', 'string', 'exists:tenants,codigo'],
'categoria_id' => [
'nullable',
'integer',
@@ -31,7 +29,6 @@ class UpdateCategoryRequest extends FormRequest
Rule::notIn([$category?->id]),
],
'nombre' => ['required', 'string', 'max:255'],
...PropRules::sync(),
];
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Models\ProductAttribute;
use App\Domains\Catalog\Support\ProductAttributeType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpdateProductAttributeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var ProductAttribute|null $attribute */
$attribute = $this->route('productAttribute');
return [
'codigo' => [
'required',
'string',
'max:255',
Rule::unique('productos_attributes', 'codigo')
->ignore($attribute?->id)
->where(fn ($query) => $query->where('tenant_codigo', $attribute?->tenant_codigo)),
],
'nombre' => ['required', 'string', 'max:255'],
'is_required' => ['sometimes', 'boolean'],
'metadata_schema' => ['nullable', 'array'],
'type' => ['required', Rule::enum(ProductAttributeType::class)],
'options' => ['sometimes', 'array'],
'options.*.label' => ['required', 'string', 'max:255'],
'options.*.sort_order' => ['sometimes', 'integer'],
'options.*.metadata' => ['nullable', 'array'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$type = $this->input('type');
$supportsOptions = in_array($type, [ProductAttributeType::Select->value, ProductAttributeType::Multiselect->value], true);
if (! $supportsOptions && $this->filled('metadata_schema')) {
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
}
if (! $supportsOptions && $this->filled('options')) {
$validator->errors()->add('options', 'Options are only allowed for select and multiselect attributes.');
}
});
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Models\Product;
use App\Domains\Prop\Support\PropRules;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -23,7 +22,6 @@ class UpdateProductRequest extends FormRequest
$product = $this->route('producto');
return [
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
'categoria_id' => ['required', 'integer'],
'slug' => [
'required',
@@ -34,7 +32,6 @@ class UpdateProductRequest extends FormRequest
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
...PropRules::sync(),
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProductVariantRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var ProductVariant|null $productVariant */
$productVariant = $this->route('productVariant');
return [
'producto_id' => [
'required',
'integer',
Rule::exists('productos', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'slug' => [
'nullable',
'string',
'max:255',
Rule::unique('productos_variantes', 'slug')
->ignore($productVariant?->id)
->where(fn ($query) => $query->where('producto_id', $this->input('producto_id'))),
],
'nombre' => ['nullable', 'string', 'max:255'],
'stock' => ['sometimes', 'integer', 'min:0'],
'descripcion' => ['nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'definitions' => ['sometimes', 'array'],
'definitions.*.attribute_id' => [
'required',
'integer',
'distinct',
Rule::exists('productos_attributes', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'definitions.*.value' => ['nullable', 'string'],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\ProductAttributeOption
*/
class ProductAttributeOptionResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'label' => $this->label,
'sort_order' => $this->sort_order,
'metadata' => $this->metadata,
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\ProductAttribute
*/
class ProductAttributeResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'tenant_codigo' => $this->tenant_codigo,
'codigo' => $this->codigo,
'nombre' => $this->nombre,
'is_required' => $this->is_required,
'metadata_schema' => $this->metadata_schema,
'type' => $this->type?->value ?? $this->type,
'options' => ProductAttributeOptionResource::collection($this->whenLoaded('options')),
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\ProductVariantDefinition
*/
class ProductVariantDefinitionResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'producto_variante_id' => $this->producto_variante_id,
'attribute_id' => $this->attribute_id,
'value' => $this->value,
'attribute' => ProductAttributeResource::make($this->whenLoaded('attribute')),
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\ProductVariant
*/
class ProductVariantResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'producto_id' => $this->producto_id,
'slug' => $this->slug,
'nombre' => $this->nombre,
'stock' => $this->stock,
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'product' => ProductResource::make($this->whenLoaded('product')),
'definitions' => ProductVariantDefinitionResource::collection($this->whenLoaded('definitions')),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Catalog\Support;
enum ProductAttributeType: string
{
case Text = 'text';
case Numeric = 'numeric';
case Select = 'select';
case Multiselect = 'multiselect';
public function supportsOptions(): bool
{
return in_array($this, [self::Select, self::Multiselect], true);
}
}

View File

@@ -3,8 +3,16 @@
use App\Domains\Catalog\Controllers\BrandController;
use App\Domains\Catalog\Controllers\CategoryController;
use App\Domains\Catalog\Controllers\ProductController;
use App\Domains\Catalog\Controllers\ProductAttributeController;
use App\Domains\Catalog\Controllers\ProductVariantController;
use Illuminate\Support\Facades\Route;
Route::apiResource('marcas', BrandController::class)->parameters(['marcas' => 'marca']);
Route::apiResource('categorias', CategoryController::class)->parameters(['categorias' => 'categoria']);
Route::apiResource('productos', ProductController::class);
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
Route::apiResource('marcas', BrandController::class)->parameters(['marcas' => 'marca']);
Route::apiResource('categorias', CategoryController::class)->parameters(['categorias' => 'categoria']);
Route::apiResource('productos', ProductController::class);
Route::apiResource('product-attributes', ProductAttributeController::class)
->parameters(['product-attributes' => 'productAttribute']);
Route::apiResource('product-variants', ProductVariantController::class)
->parameters(['product-variants' => 'productVariant']);
});

View File

@@ -1,138 +0,0 @@
<?php
namespace App\Domains\Prop\Concerns;
use App\Domains\Prop\Models\ModelPropValue;
use App\Domains\Prop\Models\Prop;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
use LogicException;
trait Propable
{
/**
* @param array<string, mixed> $attributes
* @param array<string, mixed> $props
* @return static
*/
public static function createWithProps(array $attributes, array $props = []): static
{
/** @var static $model */
$model = DB::transaction(function () use ($attributes, $props): Model {
/** @var static $createdModel */
$createdModel = static::query()->create($attributes);
$createdModel->syncPropValues($props);
return $createdModel;
});
return $model;
}
/**
* @param array<string, mixed> $attributes
*/
public static function createProp(array $attributes): Prop
{
return Prop::query()->create([
...$attributes,
'propable_type' => static::class,
]);
}
/**
* @return Builder<Prop>
*/
public function props(): Builder
{
return Prop::query()->forModel(static::class);
}
/**
* @return HasMany<ModelPropValue, $this>
*/
public function propValues(): HasMany
{
return $this->hasMany(ModelPropValue::class, 'valuable_id')
->whereHas('prop', fn (Builder $query) => $query->forModel(static::class));
}
public function getPropValue(Prop|string $prop): ?ModelPropValue
{
$resolvedProp = $this->resolveProp($prop);
return $this->propValues()
->where('prop_id', $resolvedProp->getKey())
->first();
}
public function setPropValue(Prop|string $prop, mixed $value): ModelPropValue
{
$this->ensurePropValuesCanBeManaged();
$resolvedProp = $this->resolveProp($prop);
return $this->propValues()->updateOrCreate(
['prop_id' => $resolvedProp->getKey()],
['value' => $value],
);
}
/**
* @param array<string, mixed> $props
*/
public function syncPropValues(array $props): void
{
foreach ($props as $codigo => $value) {
$this->setPropValue($codigo, $value);
}
}
/**
* @param array<string, mixed> $attributes
* @param array<string, mixed> $props
*/
public function updateWithProps(array $attributes, array $props = []): static
{
DB::transaction(function () use ($attributes, $props): void {
$this->update($attributes);
$this->syncPropValues($props);
});
return $this;
}
public function deletePropValue(Prop|string $prop): bool
{
$resolvedProp = $this->resolveProp($prop);
return $this->propValues()
->where('prop_id', $resolvedProp->getKey())
->delete() > 0;
}
protected function ensurePropValuesCanBeManaged(): void
{
if (! $this->exists) {
throw new LogicException('Cannot manage prop values for an unsaved model.');
}
}
protected function resolveProp(Prop|string $prop): Prop
{
if ($prop instanceof Prop) {
if ($prop->propable_type !== static::class) {
throw new LogicException('The given prop does not belong to this model type.');
}
return $prop;
}
return Prop::query()
->forModel(static::class)
->where('codigo', $prop)
->firstOrFail();
}
}

View File

@@ -1,70 +0,0 @@
<?php
namespace App\Domains\Prop\Controllers;
use App\Domains\Prop\Models\Prop;
use App\Domains\Prop\Requests\StorePropRequest;
use App\Domains\Prop\Requests\UpdatePropRequest;
use App\Domains\Prop\Resources\PropResource;
use App\Domains\Prop\Support\PropableModels;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class PropController extends Controller
{
public function index(string $propableType): JsonResponse
{
$modelClass = PropableModels::resolveOrFail($propableType);
return PropResource::collection(
Prop::query()->forModel($modelClass)->latest()->get()
)->response();
}
public function store(StorePropRequest $request, string $propableType): JsonResponse
{
$modelClass = PropableModels::resolveOrFail($propableType);
/** @var Prop $prop */
$prop = $modelClass::createProp($request->validated());
return PropResource::make($prop)->response()->setStatusCode(201);
}
public function show(string $propableType, Prop $prop): PropResource
{
$modelClass = PropableModels::resolveOrFail($propableType);
$prop = $this->resolveScopedProp($prop, $modelClass);
return PropResource::make($prop);
}
public function update(UpdatePropRequest $request, string $propableType, Prop $prop): PropResource
{
$modelClass = PropableModels::resolveOrFail($propableType);
$prop = $this->resolveScopedProp($prop, $modelClass);
$prop->update($request->validated());
return PropResource::make($prop);
}
public function destroy(string $propableType, Prop $prop): Response
{
$modelClass = PropableModels::resolveOrFail($propableType);
$prop = $this->resolveScopedProp($prop, $modelClass);
$prop->delete();
return response()->noContent();
}
protected function resolveScopedProp(Prop $prop, string $modelClass): Prop
{
if ($prop->propable_type !== $modelClass) {
throw new NotFoundHttpException('Prop not found for the given model type.');
}
return $prop;
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace App\Domains\Prop\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'valuable_id',
'prop_id',
'value',
])]
class ModelPropValue extends Model
{
use HasFactory;
protected $table = 'model_prop_values';
/**
* @return BelongsTo<Prop, $this>
*/
public function prop(): BelongsTo
{
return $this->belongsTo(Prop::class, 'prop_id');
}
}

View File

@@ -1,57 +0,0 @@
<?php
namespace App\Domains\Prop\Models;
use App\Domains\Prop\Support\PropDataType;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'propable_type',
'codigo',
'nombre',
'is_required',
'data_type',
])]
class Prop extends Model
{
use HasFactory;
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_required' => 'boolean',
'data_type' => PropDataType::class,
];
}
/**
* @param Builder<self> $query
*/
public function scopeForModel(Builder $query, Model|string $model): void
{
$query->where('propable_type', is_string($model) ? $model : $model::class);
}
/**
* @return HasMany<PropOption, $this>
*/
public function options(): HasMany
{
return $this->hasMany(PropOption::class);
}
/**
* @return HasMany<ModelPropValue, $this>
*/
public function modelValues(): HasMany
{
return $this->hasMany(ModelPropValue::class, 'prop_id');
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace App\Domains\Prop\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'prop_id',
'value',
'label',
'sort_order',
])]
class PropOption extends Model
{
use HasFactory;
/**
* @return BelongsTo<Prop, $this>
*/
public function prop(): BelongsTo
{
return $this->belongsTo(Prop::class);
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace App\Domains\Prop\Requests;
use App\Domains\Prop\Support\PropDataType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePropRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'codigo' => ['required', 'string', 'max:255', Rule::unique('props', 'codigo')],
'nombre' => ['required', 'string', 'max:255'],
'is_required' => ['sometimes', 'boolean'],
'data_type' => ['required', Rule::enum(PropDataType::class)],
];
}
}

View File

@@ -1,32 +0,0 @@
<?php
namespace App\Domains\Prop\Requests;
use App\Domains\Prop\Models\Prop;
use App\Domains\Prop\Support\PropDataType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdatePropRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var Prop|null $prop */
$prop = $this->route('prop');
return [
'codigo' => ['required', 'string', 'max:255', Rule::unique('props', 'codigo')->ignore($prop?->id)],
'nombre' => ['required', 'string', 'max:255'],
'is_required' => ['sometimes', 'boolean'],
'data_type' => ['required', Rule::enum(PropDataType::class)],
];
}
}

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Domains\Prop\Resources;
use App\Domains\Prop\Concerns\Propable;
use App\Domains\Prop\Models\ModelPropValue;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use InvalidArgumentException;
/**
* @mixin ModelPropValue
*/
class PropValueResource extends JsonResource
{
/**
* @param iterable<ModelPropValue> $propValues
* @return array<string, mixed>
*/
public static function collapsed(iterable $propValues): array
{
return collect($propValues)
->mapWithKeys(fn (ModelPropValue $propValue) => [
$propValue->prop->codigo => $propValue->value,
])
->all();
}
/**
* @return array<string, mixed>
*/
public static function collapsedFromPropable(object $model): array
{
if (! in_array(Propable::class, class_uses_recursive($model), true)) {
throw new InvalidArgumentException('The given model must use the Propable trait.');
}
return static::collapsed(
$model->propValues()->with('prop')->get(),
);
}
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [$this->prop->codigo => $this->value];
}
}

View File

@@ -1,26 +0,0 @@
<?php
namespace App\Domains\Prop\Support;
enum PropDataType: string
{
case String = 'string';
case Integer = 'integer';
case Decimal = 'decimal';
case Boolean = 'boolean';
case Date = 'date';
case DateTime = 'datetime';
case Json = 'json';
case Select = 'select';
/**
* @return list<string>
*/
public static function values(): array
{
return array_map(
static fn (self $type): string => $type->value,
self::cases(),
);
}
}

View File

@@ -1,17 +0,0 @@
<?php
namespace App\Domains\Prop\Support;
class PropRules
{
/**
* @return array<string, mixed>
*/
public static function sync(): array
{
return [
'props' => ['sometimes', 'array'],
'props.*' => ['nullable'],
];
}
}

View File

@@ -1,47 +0,0 @@
<?php
namespace App\Domains\Prop\Support;
use App\Domains\Catalog\Models\Brand;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Product;
use App\Domains\Prop\Concerns\Propable;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class PropableModels
{
/**
* @return array<string, class-string<Model>>
*/
public static function map(): array
{
return [
'brand' => Brand::class,
'category' => Category::class,
'product' => Product::class,
'tenant' => Tenant::class,
];
}
/**
* @return class-string<Model>
*/
public static function resolveOrFail(string $alias): string
{
$modelClass = Arr::get(static::map(), $alias);
if (! is_string($modelClass) || ! is_subclass_of($modelClass, Model::class) || ! in_array(Propable::class, class_uses_recursive($modelClass), true)) {
throw new NotFoundHttpException('Propable model not found.');
}
return $modelClass;
}
public static function aliasFor(string $modelClass): ?string
{
return array_search($modelClass, static::map(), true) ?: null;
}
}

View File

@@ -1,10 +0,0 @@
<?php
use App\Domains\Prop\Controllers\PropController;
use Illuminate\Support\Facades\Route;
Route::apiResource('prop-models.props', PropController::class)
->parameters([
'prop-models' => 'propableType',
'props' => 'prop',
]);

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Domains\StorageTest\Controllers;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\StorageTest\Requests\GenerateS3TemporaryUrlRequest;
use App\Domains\StorageTest\Requests\StoreS3TestFileRequest;
use App\Domains\StorageTest\Services\S3TestService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class S3TestController extends Controller
{
public function __construct(
protected AttachmentService $attachmentService,
protected S3TestService $s3TestService,
) {
}
public function store(StoreS3TestFileRequest $request): JsonResponse
{
$attachment = $this->attachmentService->store(
$request->file('file') ?? (string) $request->validated('file_base64'),
$request->validated('path'),
);
$temporaryUrl = $this->s3TestService->generateTemporaryUrl(
$attachment->path,
(int) $request->validated('expires_in_minutes', 10),
);
return response()->json(
[
'id' => $attachment->id,
'key' => $attachment->key,
'path' => $attachment->path,
'filename' => $attachment->filename,
'type' => $attachment->type->value,
'mime_type' => $attachment->mime_type,
'extension' => $attachment->extension,
'size' => $attachment->size,
'temporary_url' => $temporaryUrl['temporary_url'],
'temporary_url_expires_at' => $temporaryUrl['temporary_url_expires_at'],
],
201,
);
}
public function temporaryUrl(GenerateS3TemporaryUrlRequest $request): JsonResponse
{
return response()->json(
$this->s3TestService->generateTemporaryUrl(
$request->validated('path'),
(int) $request->validated('expires_in_minutes', 10),
)
);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\StorageTest\Requests;
use Illuminate\Foundation\Http\FormRequest;
class GenerateS3TemporaryUrlRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'path' => ['required', 'string', 'max:2048'],
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:1440'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\StorageTest\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreS3TestFileRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'file' => ['nullable', 'file', 'max:10240', 'required_without:file_base64'],
'file_base64' => ['nullable', 'string', 'required_without:file'],
'path' => ['required', 'string', 'max:2048'],
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:1440'],
];
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Domains\StorageTest\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
class S3TestService
{
/**
* @return array<string, int|string|null>
*/
public function storeTestFile(
UploadedFile $file,
?string $directory = null,
int $expiresInMinutes = 10,
): array {
$directory = $this->normalizeDirectory($directory);
$disk = Storage::disk('s3');
$path = $disk->putFile($directory, $file);
if (! is_string($path) || $path === '') {
Log::error('S3 upload returned an empty path.', [
'disk' => 's3',
'directory' => $directory,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getClientMimeType(),
'size' => $file->getSize(),
]);
throw new RuntimeException('No se pudo subir el archivo al disco s3.');
}
return [
'disk' => 's3',
'directory' => $directory,
'key' => $path,
'path' => $path,
'filename' => basename($path),
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getClientMimeType(),
'extension' => $file->extension(),
'size' => $file->getSize(),
'temporary_url' => $disk->temporaryUrl($path, now()->addMinutes($expiresInMinutes)),
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
];
}
/**
* @return array<string, string>
*/
public function generateTemporaryUrl(string $path, int $expiresInMinutes = 10): array
{
return [
'disk' => 's3',
'key' => $path,
'path' => $path,
'temporary_url' => $this->temporaryUrlForPath($path, $expiresInMinutes),
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
];
}
protected function temporaryUrlForPath(string $path, int $expiresInMinutes): string
{
return Storage::disk('s3')->temporaryUrl($path, now()->addMinutes($expiresInMinutes));
}
protected function normalizeDirectory(?string $directory): string
{
$directory = trim((string) $directory, '/');
if ($directory !== '') {
return $directory;
}
return 'testing/attachments/'.now()->format('Y/m/d').'/'.Str::uuid();
}
}

View File

@@ -0,0 +1,9 @@
<?php
use App\Domains\StorageTest\Controllers\S3TestController;
use Illuminate\Support\Facades\Route;
Route::prefix('storage-test/s3')->group(function (): void {
Route::post('upload', [S3TestController::class, 'store']);
Route::get('temporary-url', [S3TestController::class, 'temporaryUrl']);
});

View File

@@ -21,7 +21,6 @@ class TenantController extends Controller
{
$validated = $request->validated();
$props = $validated['props'] ?? [];
unset($validated['props']);
$tenant = Tenant::createWithProps($validated, $props);
@@ -37,9 +36,8 @@ class TenantController extends Controller
{
$validated = $request->validated();
$props = $validated['props'] ?? [];
unset($validated['props']);
$tenant = $tenant->updateWithProps($validated, $props);
$tenant->updateWithProps($validated, $props);
return TenantResource::make($tenant);
}

View File

@@ -2,60 +2,44 @@
namespace App\Domains\Tenant\Controllers;
use App\Domains\Prop\Models\Prop;
use App\Domains\Prop\Resources\PropResource;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\TenantProp;
use App\Domains\Tenant\Requests\StoreTenantPropRequest;
use App\Domains\Tenant\Requests\UpdateTenantPropRequest;
use App\Domains\Tenant\Resources\TenantPropResource;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class TenantPropController extends Controller
{
public function index(): JsonResponse
{
return PropResource::collection(
app(Tenant::class)->props()->latest()->get()
)->response();
return TenantPropResource::collection(TenantProp::query()->latest()->get())->response();
}
public function store(StoreTenantPropRequest $request): JsonResponse
{
/** @var Prop $prop */
$prop = Tenant::createProp($request->validated());
$tenantProp = TenantProp::query()->create($request->validated());
return PropResource::make($prop)->response()->setStatusCode(201);
return TenantPropResource::make($tenantProp)->response()->setStatusCode(201);
}
public function show(Prop $tenantProp): PropResource
public function show(TenantProp $tenantProp): TenantPropResource
{
return PropResource::make($this->resolveScopedProp($tenantProp));
return TenantPropResource::make($tenantProp);
}
public function update(UpdateTenantPropRequest $request, Prop $tenantProp): PropResource
public function update(UpdateTenantPropRequest $request, TenantProp $tenantProp): TenantPropResource
{
$tenantProp = $this->resolveScopedProp($tenantProp);
$tenantProp->update($request->validated());
return PropResource::make($tenantProp);
return TenantPropResource::make($tenantProp);
}
public function destroy(Prop $tenantProp): Response
public function destroy(TenantProp $tenantProp): Response
{
$tenantProp = $this->resolveScopedProp($tenantProp);
$tenantProp->delete();
return response()->noContent();
}
protected function resolveScopedProp(Prop $prop): Prop
{
if ($prop->propable_type !== Tenant::class) {
throw new NotFoundHttpException('Tenant prop not found.');
}
return $prop;
}
}

View File

@@ -2,12 +2,14 @@
namespace App\Domains\Tenant\Models;
use App\Domains\Prop\Concerns\Propable;
use App\Domains\Attachable\Models\Concerns\HasAttachments;
use App\Domains\Catalog\Models\Product;
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\Support\Facades\DB;
use LogicException;
#[Fillable([
'codigo',
@@ -16,8 +18,29 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
])]
class Tenant extends Model
{
use HasAttachments;
use HasFactory;
use Propable;
/**
* @param array<string, mixed> $attributes
* @param array<string, mixed> $props
* @return static
*/
public static function createWithProps(array $attributes, array $props = []): static
{
unset($attributes['props']);
/** @var static $tenant */
$tenant = DB::transaction(function () use ($attributes, $props): self {
/** @var static $createdTenant */
$createdTenant = static::query()->create($attributes);
$createdTenant->syncPropValues($props);
return $createdTenant;
});
return $tenant;
}
/**
* @return HasMany<Product, $this>
@@ -26,4 +49,77 @@ class Tenant extends Model
{
return $this->hasMany(Product::class, 'tenant_codigo', 'codigo');
}
/**
* @return HasMany<TenantPropValue, $this>
*/
public function propValues(): HasMany
{
return $this->hasMany(TenantPropValue::class, 'tenant_codigo', 'codigo');
}
public function getPropValue(TenantProp|string $prop): ?TenantPropValue
{
$resolvedProp = $this->resolveProp($prop);
return $this->propValues()
->where('tenant_prop_codigo', $resolvedProp->codigo)
->first();
}
public function setPropValue(TenantProp|string $prop, mixed $value): TenantPropValue
{
$this->ensurePropValuesCanBeManaged();
$resolvedProp = $this->resolveProp($prop);
return $this->propValues()->updateOrCreate(
['tenant_prop_codigo' => $resolvedProp->codigo],
['value' => $value],
);
}
/**
* @param array<string, mixed> $props
*/
public function syncPropValues(array $props): void
{
foreach ($props as $codigo => $value) {
$this->setPropValue((string) $codigo, $value);
}
}
/**
* @param array<string, mixed> $attributes
* @param array<string, mixed> $props
* @return static
*/
public function updateWithProps(array $attributes, array $props = []): static
{
unset($attributes['props']);
DB::transaction(function () use ($attributes, $props): void {
$this->update($attributes);
$this->syncPropValues($props);
});
return $this;
}
protected function ensurePropValuesCanBeManaged(): void
{
if (! $this->exists) {
throw new LogicException('Cannot manage prop values for an unsaved tenant.');
}
}
protected function resolveProp(TenantProp|string $prop): TenantProp
{
if ($prop instanceof TenantProp) {
return $prop;
}
return TenantProp::query()
->where('codigo', $prop)
->firstOrFail();
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Tenant\Models;
use App\Domains\Tenant\Support\TenantPropDataType;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'codigo',
'nombre',
'descripcion',
'is_required',
'data_type',
])]
class TenantProp extends Model
{
use HasFactory;
protected $table = 'tenant_props';
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_required' => 'boolean',
'data_type' => TenantPropDataType::class,
];
}
/**
* @return HasMany<TenantPropValue, $this>
*/
public function values(): HasMany
{
return $this->hasMany(TenantPropValue::class, 'tenant_prop_codigo', 'codigo');
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Tenant\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'tenant_codigo',
'tenant_prop_codigo',
'value',
])]
class TenantPropValue extends Model
{
use HasFactory;
protected $table = 'tenant_prop_values';
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return BelongsTo<TenantProp, $this>
*/
public function prop(): BelongsTo
{
return $this->belongsTo(TenantProp::class, 'tenant_prop_codigo', 'codigo');
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Domains\Tenant\Requests;
use App\Domains\Prop\Support\PropDataType;
use App\Domains\Tenant\Support\TenantPropDataType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -19,10 +19,11 @@ class StoreTenantPropRequest extends FormRequest
public function rules(): array
{
return [
'codigo' => ['required', 'string', 'max:255', Rule::unique('props', 'codigo')],
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenant_props', 'codigo')],
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
'is_required' => ['sometimes', 'boolean'],
'data_type' => ['required', Rule::enum(PropDataType::class)],
'data_type' => ['required', Rule::enum(TenantPropDataType::class)],
];
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Domains\Tenant\Requests;
use App\Domains\Prop\Support\PropRules;
use App\Domains\Tenant\Support\TenantPropRules;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
@@ -50,7 +50,7 @@ class StoreTenantRequest extends FormRequest
'max:255',
Rule::unique('tenants', 'dominio'),
],
...PropRules::sync(),
...TenantPropRules::sync(),
];
}
}

View File

@@ -2,8 +2,8 @@
namespace App\Domains\Tenant\Requests;
use App\Domains\Prop\Models\Prop;
use App\Domains\Prop\Support\PropDataType;
use App\Domains\Tenant\Models\TenantProp;
use App\Domains\Tenant\Support\TenantPropDataType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -19,14 +19,15 @@ class UpdateTenantPropRequest extends FormRequest
*/
public function rules(): array
{
/** @var Prop|null $tenantProp */
/** @var TenantProp|null $tenantProp */
$tenantProp = $this->route('tenantProp');
return [
'codigo' => ['required', 'string', 'max:255', Rule::unique('props', 'codigo')->ignore($tenantProp?->id)],
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenant_props', 'codigo')->ignore($tenantProp?->id)],
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
'is_required' => ['sometimes', 'boolean'],
'data_type' => ['required', Rule::enum(PropDataType::class)],
'data_type' => ['required', Rule::enum(TenantPropDataType::class)],
];
}
}

View File

@@ -2,9 +2,9 @@
namespace App\Domains\Tenant\Requests;
use App\Domains\Prop\Support\PropRules;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use App\Domains\Tenant\Support\TenantPropRules;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -59,7 +59,7 @@ class UpdateTenantRequest extends FormRequest
'max:255',
Rule::unique('tenants', 'dominio')->ignore($tenant?->id),
],
...PropRules::sync(),
...TenantPropRules::sync(),
];
}
}

View File

@@ -1,16 +1,14 @@
<?php
namespace App\Domains\Prop\Resources;
namespace App\Domains\Tenant\Resources;
use App\Domains\Prop\Models\Prop;
use App\Domains\Prop\Support\PropableModels;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Prop
* @mixin \App\Domains\Tenant\Models\TenantProp
*/
class PropResource extends JsonResource
class TenantPropResource extends JsonResource
{
/**
* @return array<string, mixed>
@@ -19,11 +17,11 @@ class PropResource extends JsonResource
{
return [
'id' => $this->id,
'propable_type' => PropableModels::aliasFor($this->propable_type) ?? $this->propable_type,
'codigo' => $this->codigo,
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
'is_required' => $this->is_required,
'data_type' => $this->data_type?->value,
'data_type' => $this->data_type?->value ?? $this->data_type,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Tenant\Resources;
use App\Domains\Prop\Resources\PropValueResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -11,6 +10,29 @@ use Illuminate\Http\Resources\Json\JsonResource;
*/
class TenantResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
protected function collapseProps(): array
{
return $this->resource->propValues()
->with('prop')
->get()
->mapWithKeys(fn ($propValue): array => [
$propValue->tenant_prop_codigo => $this->normalizePropValue($propValue->value, $propValue->prop?->data_type?->value),
])
->all();
}
protected function normalizePropValue(mixed $value, ?string $dataType): mixed
{
return match ($dataType) {
'boolean' => filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? $value,
'number' => is_numeric($value) ? $value + 0 : $value,
default => $value,
};
}
/**
* @return array<string, mixed>
*/
@@ -21,7 +43,7 @@ class TenantResource extends JsonResource
'codigo' => $this->codigo,
'nombre' => $this->nombre,
'dominio' => $this->dominio,
'props' => PropValueResource::collapsedFromPropable($this->resource)
'props' => $this->collapseProps(),
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Domains\Tenant\Support;
enum TenantPropDataType: string
{
case String = 'string';
case Number = 'number';
case Boolean = 'boolean';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Domains\Tenant\Support;
use App\Domains\Tenant\Models\TenantProp;
use Closure;
class TenantPropRules
{
/**
* @return array<string, mixed>
*/
public static function sync(): array
{
return [
'props' => [
'sometimes',
'array',
static function (string $attribute, mixed $value, Closure $fail): void {
static::validateExistingProps($attribute, $value, $fail);
},
],
'props.*' => ['nullable'],
];
}
protected static function validateExistingProps(string $attribute, mixed $value, Closure $fail): void
{
if (! is_array($value) || $value === []) {
return;
}
$propCodes = array_map('strval', array_keys($value));
$existingPropCodes = TenantProp::query()
->whereIn('codigo', $propCodes)
->pluck('codigo')
->all();
$missingPropCodes = array_values(array_diff($propCodes, $existingPropCodes));
if ($missingPropCodes === []) {
return;
}
$fail(sprintf(
'The selected %s are invalid for Tenant: %s.',
$attribute,
implode(', ', $missingPropCodes),
));
}
}

View File

@@ -9,7 +9,8 @@
"php": "^8.3",
"laravel/framework": "^13.8",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^3.0"
"laravel/tinker": "^3.0",
"league/flysystem-aws-s3-v3": "3.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",

346
composer.lock generated
View File

@@ -4,8 +4,159 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "bf97dbe15104bcde9dd03530a8a289c8",
"content-hash": "c1933c09dc4d90a389d19d7cffff4e43",
"packages": [
{
"name": "aws/aws-crt-php",
"version": "v1.2.7",
"source": {
"type": "git",
"url": "https://github.com/awslabs/aws-crt-php.git",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
"shasum": ""
},
"require": {
"php": ">=5.5"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
"yoast/phpunit-polyfills": "^1.0"
},
"suggest": {
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
},
"type": "library",
"autoload": {
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "AWS SDK Common Runtime Team",
"email": "aws-sdk-common-runtime@amazon.com"
}
],
"description": "AWS Common Runtime for PHP",
"homepage": "https://github.com/awslabs/aws-crt-php",
"keywords": [
"amazon",
"aws",
"crt",
"sdk"
],
"support": {
"issues": "https://github.com/awslabs/aws-crt-php/issues",
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
},
"time": "2024-10-18T22:15:13+00:00"
},
{
"name": "aws/aws-sdk-php",
"version": "3.386.1",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "e36bc0e97e82d68acc92b9e06f5dc544913d819a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/e36bc0e97e82d68acc92b9e06f5dc544913d819a",
"reference": "e36bc0e97e82d68acc92b9e06f5dc544913d819a",
"shasum": ""
},
"require": {
"aws/aws-crt-php": "^1.2.3",
"ext-json": "*",
"ext-pcre": "*",
"ext-simplexml": "*",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/promises": "^2.0",
"guzzlehttp/psr7": "^2.4.5",
"mtdowling/jmespath.php": "^2.9.1",
"php": ">=8.1",
"psr/http-message": "^1.0 || ^2.0",
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
},
"require-dev": {
"andrewsville/php-token-reflection": "^1.4",
"aws/aws-php-sns-message-validator": "~1.0",
"behat/behat": "~3.0",
"composer/composer": "^2.7.8",
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
"doctrine/cache": "~1.4",
"ext-dom": "*",
"ext-openssl": "*",
"ext-sockets": "*",
"phpunit/phpunit": "^10.0",
"psr/cache": "^2.0 || ^3.0",
"psr/simple-cache": "^2.0 || ^3.0",
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
"yoast/phpunit-polyfills": "^2.0"
},
"suggest": {
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
"doctrine/cache": "To use the DoctrineCacheAdapter",
"ext-curl": "To send requests using cURL",
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
"ext-pcntl": "To use client-side monitoring",
"ext-sockets": "To use client-side monitoring"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Aws\\": "src/"
},
"exclude-from-classmap": [
"src/data/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Amazon Web Services",
"homepage": "https://aws.amazon.com"
}
],
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
"homepage": "https://aws.amazon.com/sdk-for-php",
"keywords": [
"amazon",
"aws",
"cloud",
"dynamodb",
"ec2",
"glacier",
"s3",
"sdk"
],
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.386.1"
},
"time": "2026-06-23T01:24:07+00:00"
},
{
"name": "brick/math",
"version": "0.17.2",
@@ -1805,6 +1956,62 @@
},
"time": "2026-05-14T10:28:08+00:00"
},
{
"name": "league/flysystem-aws-s3-v3",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
"reference": "f8ba6a92a5c1fdcbdd89dede009a1e6e1b93ba8c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/f8ba6a92a5c1fdcbdd89dede009a1e6e1b93ba8c",
"reference": "f8ba6a92a5c1fdcbdd89dede009a1e6e1b93ba8c",
"shasum": ""
},
"require": {
"aws/aws-sdk-php": "^3.132.4",
"league/flysystem": "^2.0.0 || ^3.0.0",
"league/mime-type-detection": "^1.0.0",
"php": "^8.0.2"
},
"conflict": {
"guzzlehttp/guzzle": "<7.0",
"guzzlehttp/ringphp": "<1.1.1"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Flysystem\\AwsS3V3\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frankdejonge.nl"
}
],
"description": "AWS S3 filesystem adapter for Flysystem.",
"keywords": [
"Flysystem",
"aws",
"file",
"files",
"filesystem",
"s3",
"storage"
],
"support": {
"issues": "https://github.com/thephpleague/flysystem-aws-s3-v3/issues",
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.0.0"
},
"time": "2022-01-13T21:11:49+00:00"
},
{
"name": "league/flysystem-local",
"version": "3.31.0",
@@ -2195,6 +2402,72 @@
],
"time": "2026-01-02T08:56:05+00:00"
},
{
"name": "mtdowling/jmespath.php",
"version": "2.9.1",
"source": {
"type": "git",
"url": "https://github.com/jmespath/jmespath.php.git",
"reference": "9c208ba27ae7d90853c288b3795d6702eb251d34"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/9c208ba27ae7d90853c288b3795d6702eb251d34",
"reference": "9c208ba27ae7d90853c288b3795d6702eb251d34",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"symfony/polyfill-mbstring": "^1.17"
},
"require-dev": {
"composer/xdebug-handler": "^3.0.3",
"phpunit/phpunit": "^8.5.52"
},
"bin": [
"bin/jp.php"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.9-dev"
}
},
"autoload": {
"files": [
"src/JmesPath.php"
],
"psr-4": {
"JmesPath\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "Declaratively specify how to extract elements from a JSON document",
"keywords": [
"json",
"jsonpath"
],
"support": {
"issues": "https://github.com/jmespath/jmespath.php/issues",
"source": "https://github.com/jmespath/jmespath.php/tree/2.9.1"
},
"time": "2026-06-11T10:43:56+00:00"
},
{
"name": "nesbot/carbon",
"version": "3.12.3",
@@ -3931,6 +4204,77 @@
],
"time": "2026-01-05T13:30:16+00:00"
},
{
"name": "symfony/filesystem",
"version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
"reference": "99aec13b82b4967ec5088222c4a3ecca955949c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2",
"reference": "99aec13b82b4967ec5088222c4a3ecca955949c2",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-mbstring": "~1.8"
},
"require-dev": {
"symfony/process": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Filesystem\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/filesystem/tree/v8.1.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/finder",
"version": "v8.1.0",

View File

@@ -56,8 +56,11 @@ return [
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
'http' => [
'verify' => env('AWS_HTTP_VERIFY', false),
],
'throw' => true,
'report' => true,
],
],

View File

@@ -1,33 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('model_prop_values', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('valuable_id');
$table->foreignId('prop_id')->constrained('props')->cascadeOnUpdate()->cascadeOnDelete();
$table->text('value')->nullable();
$table->timestamps();
$table->unique(['prop_id', 'valuable_id']);
$table->index('valuable_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('model_prop_values');
}
};

View File

@@ -1,106 +0,0 @@
<?php
use App\Domains\Prop\Support\PropDataType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasTable('props')) {
return;
}
if (Schema::hasColumn('props', 'data_type_id')) {
Schema::table('props', function (Blueprint $table) {
$table->string('data_type')->nullable()->after('is_required');
});
if (Schema::hasTable('props_data_types')) {
$typeMap = DB::table('props_data_types')
->pluck('code', 'id')
->map(static fn (mixed $code): string => in_array($code, PropDataType::values(), true) ? (string) $code : PropDataType::String->value)
->all();
DB::table('props')
->select(['id', 'data_type_id'])
->orderBy('id')
->get()
->each(function (object $prop) use ($typeMap): void {
DB::table('props')
->where('id', $prop->id)
->update([
'data_type' => $typeMap[$prop->data_type_id] ?? PropDataType::String->value,
]);
});
} else {
DB::table('props')->update([
'data_type' => PropDataType::String->value,
]);
}
Schema::table('props', function (Blueprint $table) {
$table->dropForeign(['data_type_id']);
$table->dropColumn('data_type_id');
});
Schema::table('props', function (Blueprint $table) {
$table->string('data_type')->nullable(false)->change();
});
}
if (Schema::hasTable('props_data_types')) {
Schema::drop('props_data_types');
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! Schema::hasTable('props_data_types')) {
Schema::create('props_data_types', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('code')->unique();
$table->timestamps();
});
DB::table('props_data_types')->insert([
[
'id' => 1,
'name' => 'String',
'code' => PropDataType::String->value,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
if (Schema::hasTable('props') && Schema::hasColumn('props', 'data_type') && ! Schema::hasColumn('props', 'data_type_id')) {
Schema::table('props', function (Blueprint $table) {
$table->foreignId('data_type_id')->nullable()->after('is_required')->constrained('props_data_types')->cascadeOnUpdate()->restrictOnDelete();
});
$stringTypeId = DB::table('props_data_types')
->where('code', PropDataType::String->value)
->value('id');
DB::table('props')->update([
'data_type_id' => $stringTypeId,
]);
Schema::table('props', function (Blueprint $table) {
$table->foreignId('data_type_id')->nullable(false)->change();
$table->dropColumn('data_type');
});
}
}
};

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('categorias', function (Blueprint $table) {
$table->id();
$table->string('tenant_code')->nullable();
$table->unsignedBigInteger('categoria_id')->nullable();
$table->string('nombre');
$table->timestamps();
$table->foreign('tenant_code')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->restrictOnDelete();
$table->foreign('categoria_id')
->references('id')
->on('categorias')
->cascadeOnUpdate()
->restrictOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('categorias');
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('brands', function (Blueprint $table) {
$table->id();
$table->string('tenant_codigo');
$table->string('nombre');
$table->text('descripcion')->nullable();
$table->timestamps();
$table->foreign('tenant_codigo')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->restrictOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('brands');
}
};

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('productos_variantes', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('producto_id');
$table->string('slug')->nullable();
$table->string('nombre')->nullable();
$table->unsignedInteger('stock')->default(0);
$table->text('descripcion')->nullable();
$table->decimal('precio', 10, 2);
$table->timestamps();
$table->foreign('producto_id')
->references('id')
->on('productos')
->cascadeOnUpdate()
->cascadeOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('productos_variantes');
}
};

View File

@@ -11,16 +11,14 @@ return new class extends Migration
*/
public function up(): void
{
Schema::create('props', function (Blueprint $table) {
Schema::create('tenant_props', function (Blueprint $table) {
$table->id();
$table->string('propable_type');
$table->string('codigo')->unique();
$table->string('nombre');
$table->text('descripcion')->nullable();
$table->boolean('is_required')->default(false);
$table->string('data_type');
$table->timestamps();
$table->index('propable_type');
});
}
@@ -29,6 +27,6 @@ return new class extends Migration
*/
public function down(): void
{
Schema::dropIfExists('props');
Schema::dropIfExists('tenant_props');
}
};

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tenant_prop_values', function (Blueprint $table) {
$table->id();
$table->string('tenant_codigo');
$table->string('tenant_prop_codigo');
$table->text('value')->nullable();
$table->timestamps();
$table->foreign('tenant_codigo')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->foreign('tenant_prop_codigo')
->references('codigo')
->on('tenant_props')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->unique(['tenant_codigo', 'tenant_prop_codigo']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tenant_prop_values');
}
};

View File

@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('productos_attributes', function (Blueprint $table) {
$table->id();
$table->string('tenant_codigo');
$table->string('codigo');
$table->string('nombre');
$table->boolean('is_required')->default(false);
$table->json('metadata_schema')->nullable();
$table->string('type');
$table->timestamps();
$table->foreign('tenant_codigo')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->unique(['tenant_codigo', 'codigo']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('productos_attributes');
}
};

View File

@@ -11,15 +11,13 @@ return new class extends Migration
*/
public function up(): void
{
Schema::create('prop_options', function (Blueprint $table) {
Schema::create('attribute_options', function (Blueprint $table) {
$table->id();
$table->foreignId('prop_id')->constrained('props')->cascadeOnUpdate()->cascadeOnDelete();
$table->string('value');
$table->foreignId('attribute_id')->constrained('productos_attributes')->cascadeOnUpdate()->cascadeOnDelete();
$table->string('label');
$table->unsignedInteger('sort_order')->default(0);
$table->json('metadata')->nullable();
$table->timestamps();
$table->unique(['prop_id', 'value']);
});
}
@@ -28,6 +26,6 @@ return new class extends Migration
*/
public function down(): void
{
Schema::dropIfExists('prop_options');
Schema::dropIfExists('attribute_options');
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('productos_variantes_definiciones', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('producto_variante_id');
$table->foreignId('attribute_id')->constrained('productos_attributes')->cascadeOnUpdate()->cascadeOnDelete();
$table->text('value')->nullable();
$table->timestamps();
$table->foreign('producto_variante_id')
->references('id')
->on('productos_variantes')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->unique(['producto_variante_id', 'attribute_id'], 'prod_var_def_variant_attr_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('productos_variantes_definiciones');
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('carritos', function (Blueprint $table) {
$table->id();
$table->string('tenant_codigo');
$table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnUpdate()->cascadeOnDelete();
$table->string('guest_token')->nullable();
$table->string('status')->default('active');
$table->timestamps();
$table->foreign('tenant_codigo')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->unique(['tenant_codigo', 'user_id']);
$table->unique(['tenant_codigo', 'guest_token']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('carritos');
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('carrito_items', function (Blueprint $table) {
$table->id();
$table->foreignId('cart_id')->constrained('carritos')->cascadeOnUpdate()->cascadeOnDelete();
$table->unsignedBigInteger('producto_variante_id');
$table->unsignedInteger('cantidad');
$table->timestamps();
$table->foreign('producto_variante_id')
->references('id')
->on('productos_variantes')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->unique(['cart_id', 'producto_variante_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('carrito_items');
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('attachments', function (Blueprint $table) {
$table->id();
$table->string('attachable_type');
$table->unsignedBigInteger('attachable_id');
$table->uuid('key')->unique();
$table->string('path');
$table->string('filename');
$table->string('type');
$table->string('mime_type');
$table->string('extension', 20)->nullable();
$table->unsignedBigInteger('size')->default(0);
$table->timestamps();
$table->index(['attachable_type', 'attachable_id']);
$table->index('type');
$table->index('mime_type');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('attachments');
}
};

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('attachments', function (Blueprint $table): void {
$table->dropIndex(['attachable_type', 'attachable_id']);
$table->dropColumn(['attachable_type', 'attachable_id']);
});
Schema::create('attachable_attachments', function (Blueprint $table): void {
$table->id();
$table->string('attachable_type');
$table->unsignedBigInteger('attachable_id');
$table->foreignId('attachment_id')->constrained('attachments')->cascadeOnDelete();
$table->unique(['attachable_type', 'attachable_id', 'attachment_id'], 'attachable_attachments_unique');
$table->index(['attachable_type', 'attachable_id'], 'attachable_attachments_attachable_index');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('attachable_attachments');
Schema::table('attachments', function (Blueprint $table): void {
$table->string('attachable_type');
$table->unsignedBigInteger('attachable_id');
$table->index(['attachable_type', 'attachable_id']);
});
}
};

View File

@@ -0,0 +1,95 @@
{
"info": {
"_postman_id": "8faefdb8-734a-4262-a3b6-4d49e56ea901",
"name": "Storage Test S3",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{
"key": "base_url",
"value": "http://127.0.0.1:8000"
},
{
"key": "expires_in_minutes",
"value": "10"
},
{
"key": "path",
"value": ""
}
],
"item": [
{
"name": "Upload Test File",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": []
},
{
"key": "directory",
"value": "testing/manual",
"type": "text"
},
{
"key": "expires_in_minutes",
"value": "{{expires_in_minutes}}",
"type": "text"
}
]
},
"url": {
"raw": "{{base_url}}/api/storage-test/s3/upload",
"host": [
"{{base_url}}"
],
"path": [
"api",
"storage-test",
"s3",
"upload"
]
},
"description": "Sube un archivo al disco s3 y devuelve el path junto con una temporary_url."
},
"response": []
},
{
"name": "Generate Temporary URL",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/storage-test/s3/temporary-url?path={{path}}&expires_in_minutes={{expires_in_minutes}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"storage-test",
"s3",
"temporary-url"
],
"query": [
{
"key": "path",
"value": "{{path}}"
},
{
"key": "expires_in_minutes",
"value": "{{expires_in_minutes}}"
}
]
},
"description": "Genera una URL temporal para un path ya existente en S3."
},
"response": []
}
]
}

View File

@@ -8,5 +8,6 @@ Route::get('/user', function (Request $request) {
})->middleware('auth:sanctum');
require __DIR__.'/../app/Domains/Catalog/routes/api.php';
require __DIR__.'/../app/Domains/Prop/routes/api.php';
require __DIR__.'/../app/Domains/Cart/routes/api.php';
require __DIR__.'/../app/Domains/StorageTest/routes/api.php';
require __DIR__.'/../app/Domains/Tenant/routes/api.php';

View File

@@ -0,0 +1,171 @@
<?php
namespace Tests\Feature\Attachable;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Exceptions\AttachmentStorageException;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Mockery;
use Tests\TestCase;
class AttachmentTest extends TestCase
{
use RefreshDatabase;
public function test_it_uploads_to_s3_before_persisting_the_attachment(): void
{
Storage::fake('s3');
$tenant = Tenant::query()->create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.com',
]);
$file = UploadedFile::fake()->image('logo.png');
$attachment = app(AttachmentService::class)->store(
$file,
'attachments/acme/logo.png',
);
$tenant->attachments()->attach($attachment->getKey());
$this->assertSame(AttachmentType::Image, $attachment->type);
$this->assertTrue(Str::isUuid($attachment->key));
$this->assertSame('logo.png', $attachment->filename);
$this->assertSame('attachments/acme/'.$attachment->key, $attachment->path);
Storage::disk('s3')->assertExists('attachments/acme/'.$attachment->key);
$this->assertDatabaseHas('attachments', [
'id' => $attachment->id,
'key' => $attachment->key,
'path' => 'attachments/acme/'.$attachment->key,
'filename' => 'logo.png',
'type' => AttachmentType::Image->value,
]);
$this->assertDatabaseHas('attachable_attachments', [
'attachable_type' => $tenant->getMorphClass(),
'attachable_id' => $tenant->getKey(),
'attachment_id' => $attachment->id,
]);
$freshAttachment = Attachment::query()->findOrFail($attachment->id);
$this->assertSame(AttachmentType::Image, $freshAttachment->type);
$this->assertTrue($tenant->attachments->contains($freshAttachment));
}
public function test_it_does_not_persist_the_attachment_when_the_s3_upload_fails(): void
{
$tenant = Tenant::query()->create([
'codigo' => 'globex',
'nombre' => 'Globex',
'dominio' => 'globex.com',
]);
$disk = Mockery::mock();
Storage::shouldReceive('disk')
->once()
->with('s3')
->andReturn($disk);
$disk->shouldReceive('putFileAs')
->once()
->with('attachments/globex', Mockery::type(UploadedFile::class), Mockery::on(static fn (string $value): bool => Str::isUuid($value)))
->andReturn(false);
try {
app(AttachmentService::class)->store(
UploadedFile::fake()->create('manual.pdf', 10, 'application/pdf'),
'attachments/globex/manual.pdf',
);
$this->fail('Expected an AttachmentStorageException to be thrown.');
} catch (AttachmentStorageException) {
$this->assertDatabaseCount('attachments', 0);
}
}
public function test_it_deletes_from_s3_before_removing_the_database_record(): void
{
Storage::fake('s3');
$tenant = Tenant::query()->create([
'codigo' => 'initech',
'nombre' => 'Initech',
'dominio' => 'initech.com',
]);
Storage::disk('s3')->put('attachments/initech/spec.pdf', 'spec');
$attachment = Attachment::query()->create([
'path' => 'attachments/initech/spec.pdf',
'key' => (string) Str::uuid(),
'filename' => 'spec.pdf',
'type' => AttachmentType::Pdf,
'mime_type' => 'application/pdf',
'extension' => 'pdf',
'size' => 512,
]);
$tenant->attachments()->attach($attachment->getKey());
app(AttachmentService::class)->delete($attachment);
Storage::disk('s3')->assertMissing('attachments/initech/spec.pdf');
$this->assertDatabaseMissing('attachments', [
'id' => $attachment->id,
]);
$this->assertDatabaseMissing('attachable_attachments', [
'attachment_id' => $attachment->id,
]);
}
public function test_it_keeps_the_database_record_when_the_s3_delete_fails(): void
{
$tenant = Tenant::query()->create([
'codigo' => 'umbrella',
'nombre' => 'Umbrella',
'dominio' => 'umbrella.com',
]);
$attachment = Attachment::query()->create([
'path' => 'attachments/umbrella/audio.mp3',
'key' => (string) Str::uuid(),
'filename' => 'audio.mp3',
'type' => AttachmentType::Audio,
'mime_type' => 'audio/mpeg',
'extension' => 'mp3',
'size' => 1024,
]);
$tenant->attachments()->attach($attachment->getKey());
$disk = Mockery::mock();
Storage::shouldReceive('disk')
->once()
->with('s3')
->andReturn($disk);
$disk->shouldReceive('delete')
->once()
->with('attachments/umbrella/audio.mp3')
->andReturn(false);
try {
app(AttachmentService::class)->delete($attachment);
$this->fail('Expected an AttachmentStorageException to be thrown.');
} catch (AttachmentStorageException) {
$this->assertDatabaseHas('attachments', [
'id' => $attachment->id,
]);
$this->assertDatabaseHas('attachable_attachments', [
'attachment_id' => $attachment->id,
'attachable_type' => $tenant->getMorphClass(),
'attachable_id' => $tenant->getKey(),
]);
}
}
}

View File

@@ -0,0 +1,303 @@
<?php
namespace Tests\Feature\Cart;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductAttribute;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Models\ProductVariantDefinition;
use App\Domains\Tenant\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CartControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_returns_an_empty_guest_cart_when_cart_does_not_exist(): void
{
Tenant::create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.com',
]);
$this->getJson('/api/tenants/acme/cart')
->assertOk()
->assertJson([
'id' => null,
'tenant_codigo' => 'acme',
'status' => 'active',
'items' => [],
'subtotal' => '0.00',
]);
}
public function test_it_creates_a_guest_cart_and_returns_the_cart_snapshot(): void
{
$variant = $this->createVariantForTenant('acme', 10, '49.90');
$attribute = ProductAttribute::query()->create([
'tenant_codigo' => 'acme',
'codigo' => 'color',
'nombre' => 'Color',
'type' => 'text',
]);
ProductVariantDefinition::query()->create([
'producto_variante_id' => $variant->id,
'attribute_id' => $attribute->id,
'value' => 'Red',
]);
$response = $this->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $variant->id,
'cantidad' => 2,
]);
$response
->assertOk()
->assertCookie('guest_token')
->assertJsonPath('tenant_codigo', 'acme')
->assertJsonPath('items.0.cantidad', 2)
->assertJsonPath('items.0.precio_unitario', '49.90')
->assertJsonPath('items.0.subtotal', '99.80')
->assertJsonPath('items.0.product.id', $variant->product->id)
->assertJsonPath('items.0.variant.id', $variant->id)
->assertJsonPath('items.0.variant.definitions.0.attribute.codigo', 'color')
->assertJsonPath('subtotal', '99.80');
$this->assertDatabaseHas('carritos', [
'tenant_codigo' => 'acme',
'guest_token' => $response->getCookie('guest_token')?->getValue(),
'status' => 'active',
]);
$this->assertDatabaseHas('carrito_items', [
'producto_variante_id' => $variant->id,
'cantidad' => 2,
]);
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock' => 8,
]);
}
public function test_it_merges_quantities_when_the_same_guest_adds_the_same_variant_twice(): void
{
$variant = $this->createVariantForTenant('acme', 12, '25.00');
$firstResponse = $this->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $variant->id,
'cantidad' => 2,
]);
$guestToken = $firstResponse->getCookie('guest_token')?->getValue();
$this->withCookie('guest_token', $guestToken)
->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $variant->id,
'cantidad' => 3,
])
->assertOk()
->assertJsonPath('items.0.cantidad', 5)
->assertJsonPath('items.0.subtotal', '125.00')
->assertJsonPath('subtotal', '125.00');
$this->assertDatabaseCount('carritos', 1);
$this->assertDatabaseCount('carrito_items', 1);
$this->assertDatabaseHas('carrito_items', [
'producto_variante_id' => $variant->id,
'cantidad' => 5,
]);
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock' => 7,
]);
}
public function test_it_updates_item_quantity_and_adjusts_stock(): void
{
$variant = $this->createVariantForTenant('acme', 10, '15.00');
$createResponse = $this->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $variant->id,
'cantidad' => 2,
]);
$guestToken = $createResponse->getCookie('guest_token')?->getValue();
$this->withCookie('guest_token', $guestToken)
->patchJson("/api/tenants/acme/cart/items/{$variant->id}", [
'cantidad' => 5,
])
->assertOk()
->assertJsonPath('items.0.cantidad', 5)
->assertJsonPath('items.0.subtotal', '75.00')
->assertJsonPath('subtotal', '75.00');
$this->assertDatabaseHas('carrito_items', [
'producto_variante_id' => $variant->id,
'cantidad' => 5,
]);
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock' => 5,
]);
}
public function test_it_removes_an_item_and_restores_stock(): void
{
$variant = $this->createVariantForTenant('acme', 10, '15.00');
$createResponse = $this->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $variant->id,
'cantidad' => 4,
]);
$guestToken = $createResponse->getCookie('guest_token')?->getValue();
$this->withCookie('guest_token', $guestToken)
->deleteJson("/api/tenants/acme/cart/items/{$variant->id}")
->assertOk()
->assertJsonPath('items', [])
->assertJsonPath('subtotal', '0.00');
$this->assertDatabaseCount('carrito_items', 0);
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock' => 10,
]);
}
public function test_authenticated_users_reuse_the_same_cart_per_tenant_and_get_a_new_one_for_another_tenant(): void
{
$user = User::factory()->create();
$acmeVariantA = $this->createVariantForTenant('acme', 10, '10.00');
$acmeVariantB = $this->createVariantForTenant('acme', 8, '20.00', 'hoodie');
$globexVariant = $this->createVariantForTenant('globex', 6, '30.00');
$this->actingAs($user)
->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $acmeVariantA->id,
'cantidad' => 1,
])
->assertOk();
$this->actingAs($user)
->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $acmeVariantB->id,
'cantidad' => 2,
])
->assertOk()
->assertJsonPath('subtotal', '50.00');
$this->actingAs($user)
->postJson('/api/tenants/globex/cart/items', [
'product_variant_id' => $globexVariant->id,
'cantidad' => 1,
])
->assertOk();
$this->assertDatabaseCount('carritos', 2);
$this->assertDatabaseHas('carritos', [
'tenant_codigo' => 'acme',
'user_id' => $user->id,
]);
$this->assertDatabaseHas('carritos', [
'tenant_codigo' => 'globex',
'user_id' => $user->id,
]);
}
public function test_it_rejects_variants_from_another_tenant(): void
{
$this->createVariantForTenant('acme', 10, '10.00');
$otherVariant = $this->createVariantForTenant('globex', 10, '20.00');
$this->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $otherVariant->id,
'cantidad' => 1,
])->assertNotFound();
}
public function test_it_returns_not_found_when_the_cart_item_does_not_exist_for_update_or_delete(): void
{
$variant = $this->createVariantForTenant('acme', 10, '10.00');
$this->patchJson("/api/tenants/acme/cart/items/{$variant->id}", [
'cantidad' => 2,
])->assertNotFound();
$this->deleteJson("/api/tenants/acme/cart/items/{$variant->id}")
->assertNotFound();
}
public function test_it_validates_quantity_and_stock_constraints(): void
{
$variant = $this->createVariantForTenant('acme', 2, '10.00');
$this->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $variant->id,
'cantidad' => 0,
])->assertUnprocessable()->assertJsonValidationErrors(['cantidad']);
$response = $this->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $variant->id,
'cantidad' => 2,
]);
$guestToken = $response->getCookie('guest_token')?->getValue();
$this->withCookie('guest_token', $guestToken)
->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $variant->id,
'cantidad' => 1,
])
->assertUnprocessable()
->assertJsonValidationErrors(['cantidad']);
$this->withCookie('guest_token', $guestToken)
->patchJson("/api/tenants/acme/cart/items/{$variant->id}", [
'cantidad' => 3,
])
->assertUnprocessable()
->assertJsonValidationErrors(['cantidad']);
}
protected function createVariantForTenant(
string $tenantCode,
int $stock,
string $price,
string $slugPrefix = 'shirt',
): ProductVariant {
Tenant::query()->firstOrCreate(
['codigo' => $tenantCode],
['nombre' => ucfirst($tenantCode), 'dominio' => "{$tenantCode}.com"],
);
$category = \App\Domains\Catalog\Models\Category::query()->create([
'tenant_code' => $tenantCode,
'nombre' => "{$slugPrefix} category {$tenantCode}",
]);
$product = Product::query()->create([
'tenant_codigo' => $tenantCode,
'categoria_id' => $category->id,
'slug' => "{$slugPrefix}-{$tenantCode}-".Product::query()->count(),
'nombre' => ucfirst($slugPrefix)." {$tenantCode}",
'descripcion' => 'Test product',
'precio' => $price,
]);
return ProductVariant::query()->create([
'producto_id' => $product->id,
'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(),
'nombre' => ucfirst($slugPrefix).' Variant',
'stock' => $stock,
'descripcion' => 'Test variant',
'precio' => $price,
])->load('product');
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Tests\Feature\Catalog;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProductAttributeControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_a_select_attribute_with_options(): void
{
Tenant::create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.com',
]);
$response = $this->postJson('/api/tenants/acme/product-attributes', [
'codigo' => 'color',
'nombre' => 'Color',
'is_required' => true,
'type' => 'select',
'metadata_schema' => [
'swatch' => ['type' => 'hex'],
],
'options' => [
[
'label' => 'Red',
'sort_order' => 1,
'metadata' => ['hex' => '#ff0000'],
],
[
'label' => 'Blue',
'sort_order' => 2,
'metadata' => ['hex' => '#0000ff'],
],
],
]);
$response
->assertCreated()
->assertJsonPath('tenant_codigo', 'acme')
->assertJsonPath('codigo', 'color')
->assertJsonPath('type', 'select')
->assertJsonPath('options.0.label', 'Red')
->assertJsonPath('options.1.metadata.hex', '#0000ff');
$this->assertDatabaseHas('productos_attributes', [
'tenant_codigo' => 'acme',
'codigo' => 'color',
'type' => 'select',
]);
$this->assertDatabaseHas('attribute_options', [
'label' => 'Red',
'sort_order' => 1,
]);
}
public function test_it_rejects_options_for_text_attributes(): void
{
Tenant::create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.com',
]);
$response = $this->postJson('/api/tenants/acme/product-attributes', [
'codigo' => 'material',
'nombre' => 'Material',
'type' => 'text',
'options' => [
['label' => 'Cotton'],
],
]);
$response
->assertUnprocessable()
->assertJsonValidationErrors(['options']);
}
}

View File

@@ -1,45 +0,0 @@
<?php
namespace Tests\Feature\Prop;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PropControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_a_prop_using_enum_data_type(): void
{
$response = $this->postJson('/api/prop-models/tenant/props', [
'codigo' => 'color_primario',
'nombre' => 'Color primario',
'is_required' => false,
'data_type' => 'string',
]);
$response
->assertCreated()
->assertJsonPath('propable_type', 'tenant')
->assertJsonPath('data_type', 'string');
$this->assertDatabaseHas('props', [
'codigo' => 'color_primario',
'data_type' => 'string',
]);
}
public function test_it_rejects_unknown_enum_data_type(): void
{
$response = $this->postJson('/api/prop-models/tenant/props', [
'codigo' => 'color_primario',
'nombre' => 'Color primario',
'is_required' => false,
'data_type' => 'unsupported-type',
]);
$response
->assertUnprocessable()
->assertJsonValidationErrors(['data_type']);
}
}

View File

@@ -3,6 +3,7 @@
namespace Tests\Feature\Tenant;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\TenantProp;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -18,9 +19,10 @@ class BootstrapTenantControllerTest extends TestCase
'dominio' => 'acme.com',
]);
Tenant::createProp([
TenantProp::create([
'codigo' => 'primary_color',
'nombre' => 'Primary Color',
'descripcion' => 'Brand color',
'is_required' => false,
'data_type' => 'string',
]);
@@ -63,15 +65,33 @@ class BootstrapTenantControllerTest extends TestCase
public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void
{
TenantProp::create([
'codigo' => 'primary_color',
'nombre' => 'Primary Color',
'descripcion' => 'Brand color',
'is_required' => false,
'data_type' => 'string',
]);
$firstResponse = $this->postJson('/api/tenants', [
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'https://ACME.com/path',
'props' => [
'primary_color' => 'blue',
],
]);
$firstResponse
->assertCreated()
->assertJsonPath('dominio', 'acme.com');
->assertJsonPath('dominio', 'acme.com')
->assertJsonPath('props.primary_color', 'blue');
$this->assertDatabaseHas('tenant_prop_values', [
'tenant_codigo' => 'acme',
'tenant_prop_codigo' => 'primary_color',
'value' => 'blue',
]);
$secondResponse = $this->postJson('/api/tenants', [
'codigo' => 'globex',
@@ -86,6 +106,14 @@ class BootstrapTenantControllerTest extends TestCase
public function test_it_allows_keeping_the_same_domain_on_update_but_rejects_collisions(): void
{
TenantProp::create([
'codigo' => 'primary_color',
'nombre' => 'Primary Color',
'descripcion' => 'Brand color',
'is_required' => false,
'data_type' => 'string',
]);
$tenant = Tenant::create([
'codigo' => 'acme',
'nombre' => 'Acme',
@@ -102,12 +130,16 @@ class BootstrapTenantControllerTest extends TestCase
'codigo' => 'acme',
'nombre' => 'Acme Updated',
'dominio' => 'https://ACME.com:443/admin',
'props' => [
'primary_color' => 'green',
],
]);
$successfulResponse
->assertOk()
->assertJsonPath('nombre', 'Acme Updated')
->assertJsonPath('dominio', 'acme.com');
->assertJsonPath('dominio', 'acme.com')
->assertJsonPath('props.primary_color', 'green');
$failingResponse = $this->putJson("/api/tenants/{$otherTenant->id}", [
'codigo' => 'globex',

View File

@@ -0,0 +1,45 @@
<?php
namespace Tests\Feature\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TenantPropControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_a_tenant_prop(): void
{
$response = $this->postJson('/api/tenant-props', [
'codigo' => 'primary_color',
'nombre' => 'Primary Color',
'descripcion' => 'Brand color',
'is_required' => false,
'data_type' => 'string',
]);
$response
->assertCreated()
->assertJsonPath('codigo', 'primary_color')
->assertJsonPath('data_type', 'string');
$this->assertDatabaseHas('tenant_props', [
'codigo' => 'primary_color',
'data_type' => 'string',
]);
}
public function test_it_rejects_unknown_data_type(): void
{
$response = $this->postJson('/api/tenant-props', [
'codigo' => 'primary_color',
'nombre' => 'Primary Color',
'data_type' => 'unsupported',
]);
$response
->assertUnprocessable()
->assertJsonValidationErrors(['data_type']);
}
}