diff --git a/app/Domains/Catalog/Controllers/ProductController.php b/app/Domains/Catalog/Controllers/ProductController.php index 3eb1c96..8058c22 100644 --- a/app/Domains/Catalog/Controllers/ProductController.php +++ b/app/Domains/Catalog/Controllers/ProductController.php @@ -18,7 +18,11 @@ class ProductController extends Controller public function index(Tenant $tenant): JsonResponse { return ProductResource::collection( - Product::query()->where('tenant_codigo', $tenant->codigo)->latest()->paginateFromRequest() + Product::query() + ->where('tenant_codigo', $tenant->codigo) + ->with('attachments') + ->latest() + ->paginateFromRequest() )->response(); } @@ -33,7 +37,7 @@ class ProductController extends Controller { $producto = $this->resolveScopedProduct($tenant, $producto); - return ProductResource::make($producto->load('variants.definitions.attribute')); + return ProductResource::make($producto->load(['attachments', 'variants.definitions.attribute', 'variants.attachments'])); } public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource diff --git a/app/Domains/Catalog/Models/Product.php b/app/Domains/Catalog/Models/Product.php index 6f6f92e..05bbc7a 100644 --- a/app/Domains/Catalog/Models/Product.php +++ b/app/Domains/Catalog/Models/Product.php @@ -2,6 +2,7 @@ namespace App\Domains\Catalog\Models; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -61,6 +62,19 @@ class Product extends Model )->withTimestamps(); } + /** + * @return BelongsToMany + */ + public function attachments(): BelongsToMany + { + return $this->belongsToMany( + Attachment::class, + 'productos_attachments', + 'producto_id', + 'attachment_id' + )->withTimestamps(); + } + /** * Create a variant for this product with its definitions. * diff --git a/app/Domains/Catalog/Requests/StoreProductRequest.php b/app/Domains/Catalog/Requests/StoreProductRequest.php index 5405f93..76a308c 100644 --- a/app/Domains/Catalog/Requests/StoreProductRequest.php +++ b/app/Domains/Catalog/Requests/StoreProductRequest.php @@ -2,6 +2,7 @@ namespace App\Domains\Catalog\Requests; +use App\Domains\Shared\Rules\ImageOrBase64Rule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -31,6 +32,8 @@ class StoreProductRequest extends FormRequest fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) ), ], + 'images' => ['sometimes', 'nullable', 'array'], + 'images.*' => ['required', new ImageOrBase64Rule()], ]; } } diff --git a/app/Domains/Catalog/Requests/UpdateProductRequest.php b/app/Domains/Catalog/Requests/UpdateProductRequest.php index ce189bd..b556545 100644 --- a/app/Domains/Catalog/Requests/UpdateProductRequest.php +++ b/app/Domains/Catalog/Requests/UpdateProductRequest.php @@ -3,6 +3,7 @@ namespace App\Domains\Catalog\Requests; use App\Domains\Catalog\Models\Product; +use App\Domains\Shared\Rules\ImageOrBase64Rule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -40,6 +41,8 @@ class UpdateProductRequest extends FormRequest fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) ), ], + 'images' => ['sometimes', 'nullable', 'array'], + 'images.*' => ['required', new ImageOrBase64Rule()], ]; } } diff --git a/app/Domains/Catalog/Resources/ProductResource.php b/app/Domains/Catalog/Resources/ProductResource.php index 08b5f50..42b8a23 100644 --- a/app/Domains/Catalog/Resources/ProductResource.php +++ b/app/Domains/Catalog/Resources/ProductResource.php @@ -23,6 +23,9 @@ class ProductResource extends JsonResource 'nombre' => $this->nombre, 'descripcion' => $this->descripcion, 'precio' => $this->precio, + 'images' => $this->whenLoaded('attachments', fn () => + $this->attachments->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))->values() + ), 'variants' => ProductVariantResource::collection($this->whenLoaded('variants')), 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, diff --git a/app/Domains/Catalog/Resources/ProductVariantResource.php b/app/Domains/Catalog/Resources/ProductVariantResource.php index 94f8a98..f2c37f7 100644 --- a/app/Domains/Catalog/Resources/ProductVariantResource.php +++ b/app/Domains/Catalog/Resources/ProductVariantResource.php @@ -25,9 +25,21 @@ class ProductVariantResource extends JsonResource 'precio' => $this->precio, 'product' => ProductResource::make($this->whenLoaded('product')), 'definitions' => ProductVariantDefinitionResource::collection($this->whenLoaded('definitions')), - 'images' => $this->whenLoaded('attachments', fn () => - $this->attachments->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))->values() - ), + 'images' => $this->whenLoaded('attachments', function () { + if ($this->attachments->isNotEmpty()) { + return $this->attachments + ->map(fn ($attachment) => $attachment->getTemporaryUrl(1440)) + ->values(); + } + + // Fallback: use product-level attachments when the variant has none + $this->loadMissing('product.attachments'); + + return $this->product?->attachments + ?->map(fn ($attachment) => $attachment->getTemporaryUrl(1440)) + ?->values() + ?? collect(); + }), 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; diff --git a/app/Domains/Catalog/Services/ProductService.php b/app/Domains/Catalog/Services/ProductService.php index 74fe24d..c4722eb 100644 --- a/app/Domains/Catalog/Services/ProductService.php +++ b/app/Domains/Catalog/Services/ProductService.php @@ -22,7 +22,8 @@ class ProductService { return DB::transaction(function () use ($tenant, $data) { $attributeIds = $data['attribute_ids'] ?? []; - unset($data['attribute_ids']); + $images = $data['images'] ?? []; + unset($data['attribute_ids'], $data['images']); /** @var Product $product */ $product = Product::query()->create([ @@ -32,7 +33,11 @@ class ProductService $product->attributes()->sync($attributeIds); - return $product->load('attributes'); + if (! empty($images)) { + $this->syncProductImages($product, $images); + } + + return $product->load(['attributes', 'attachments']); }); } @@ -46,7 +51,9 @@ class ProductService return DB::transaction(function () use ($product, $data) { $hasAttributeIds = array_key_exists('attribute_ids', $data); $attributeIds = $data['attribute_ids'] ?? []; - unset($data['attribute_ids']); + $hasImages = array_key_exists('images', $data); + $images = $data['images'] ?? []; + unset($data['attribute_ids'], $data['images']); // Ensure tenant_codigo cannot be updated/changed unset($data['tenant_codigo']); @@ -57,7 +64,11 @@ class ProductService $product->attributes()->sync($attributeIds); } - return $product->load('attributes'); + if ($hasImages) { + $this->syncProductImages($product, $images); + } + + return $product->load(['attributes', 'attachments']); }); } @@ -70,6 +81,14 @@ class ProductService foreach ($product->variants as $variant) { $product->deleteVariant($variant); } + + // Delete product-level attachments from S3 and database + $existing = $product->attachments()->get(); + $product->attachments()->detach(); + foreach ($existing as $attachment) { + $this->attachmentService->delete($attachment); + } + $product->attributes()->detach(); $product->delete(); }); @@ -183,6 +202,32 @@ class ProductService $variant->attachments()->sync($attachmentIds); } + /** + * Upload a list of image files/base64 strings and sync them to a product. + * + * Same logic as syncImages but for products without variants. + * + * @param array $images + */ + protected function syncProductImages(Product $product, array $images): void + { + // Detach pivot record and delete attachment from S3 and database + $existing = $product->attachments()->get(); + $product->attachments()->detach(); + foreach ($existing as $attachment) { + $this->attachmentService->delete($attachment); + } + + $attachmentIds = []; + + foreach ($images as $image) { + $attachment = $this->attachmentService->store($image, 'products'); + $attachmentIds[] = $attachment->id; + } + + $product->attachments()->sync($attachmentIds); + } + /** * Delete an attribute. */ diff --git a/database/migrations/2026_06_29_150840_create_productos_attachments_table.php b/database/migrations/2026_06_29_150840_create_productos_attachments_table.php new file mode 100644 index 0000000..145d64a --- /dev/null +++ b/database/migrations/2026_06_29_150840_create_productos_attachments_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('producto_id') + ->constrained('productos') + ->cascadeOnDelete(); + $table->foreignId('attachment_id') + ->constrained('attachments') + ->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['producto_id', 'attachment_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('productos_attachments'); + } +};