From 1907178e52516b22a5aa9da8f3ac6e8a09fe9cc9 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 24 Jun 2026 15:16:38 -0300 Subject: [PATCH] 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 --- .../Catalog/Controllers/BrandController.php | 34 +++++++++++---- .../Controllers/CategoryController.php | 36 ++++++++++++---- .../ProductAttributeController.php | 42 +++++++++++++------ .../Catalog/Controllers/ProductController.php | 34 +++++++++++---- .../Catalog/Requests/StoreBrandRequest.php | 1 - .../Catalog/Requests/StoreCategoryRequest.php | 1 - .../Requests/StoreProductAttributeRequest.php | 3 +- .../Catalog/Requests/StoreProductRequest.php | 1 - .../Catalog/Requests/UpdateBrandRequest.php | 1 - .../Requests/UpdateCategoryRequest.php | 1 - .../Catalog/Requests/UpdateProductRequest.php | 1 - app/Domains/Catalog/routes/api.php | 12 +++--- .../ProductAttributeControllerTest.php | 6 +-- 13 files changed, 123 insertions(+), 50 deletions(-) diff --git a/app/Domains/Catalog/Controllers/BrandController.php b/app/Domains/Catalog/Controllers/BrandController.php index 1529e0a..36ffb8c 100644 --- a/app/Domains/Catalog/Controllers/BrandController.php +++ b/app/Domains/Catalog/Controllers/BrandController.php @@ -6,40 +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 { - $brand = Brand::query()->create($request->validated()); + $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 { + $marca = $this->resolveScopedBrand($tenant, $marca); + return BrandResource::make($marca); } - public function update(UpdateBrandRequest $request, Brand $marca): BrandResource + 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(Brand $marca): Response + 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; + } } diff --git a/app/Domains/Catalog/Controllers/CategoryController.php b/app/Domains/Catalog/Controllers/CategoryController.php index c870e64..e2357dd 100644 --- a/app/Domains/Catalog/Controllers/CategoryController.php +++ b/app/Domains/Catalog/Controllers/CategoryController.php @@ -6,44 +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 { - $category = Category::query()->create($request->validated()); + $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 { + $categoria = $this->resolveScopedCategory($tenant, $categoria); + return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant'])); } - public function update(UpdateCategoryRequest $request, Category $categoria): CategoryResource + 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(Category $categoria): Response + 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; + } } diff --git a/app/Domains/Catalog/Controllers/ProductAttributeController.php b/app/Domains/Catalog/Controllers/ProductAttributeController.php index 3fbbd21..7dd7cd0 100644 --- a/app/Domains/Catalog/Controllers/ProductAttributeController.php +++ b/app/Domains/Catalog/Controllers/ProductAttributeController.php @@ -7,27 +7,28 @@ 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(): JsonResponse + public function index(Tenant $tenant): JsonResponse { - $query = ProductAttribute::query()->with('options')->latest(); - - if (request()->filled('tenant_codigo')) { - $query->where('tenant_codigo', request('tenant_codigo')); - } + $query = ProductAttribute::query() + ->where('tenant_codigo', $tenant->codigo) + ->with('options') + ->latest(); return ProductAttributeResource::collection($query->get())->response(); } - public function store(StoreProductAttributeRequest $request): JsonResponse + public function store(StoreProductAttributeRequest $request, Tenant $tenant): JsonResponse { - $attribute = DB::transaction(function () use ($request): ProductAttribute { + $attribute = DB::transaction(function () use ($request, $tenant): ProductAttribute { $validated = $request->validated(); $options = $validated['options'] ?? []; unset($validated['options']); @@ -39,7 +40,10 @@ class ProductAttributeController extends Controller } /** @var ProductAttribute $attribute */ - $attribute = ProductAttribute::query()->create($validated); + $attribute = ProductAttribute::query()->create([ + ...$validated, + 'tenant_codigo' => $tenant->codigo, + ]); $attribute->options()->createMany($options); return $attribute->load('options'); @@ -48,13 +52,17 @@ class ProductAttributeController extends Controller return ProductAttributeResource::make($attribute)->response()->setStatusCode(201); } - public function show(ProductAttribute $productAttribute): ProductAttributeResource + public function show(Tenant $tenant, ProductAttribute $productAttribute): ProductAttributeResource { + $productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute); + return ProductAttributeResource::make($productAttribute->load('options')); } - public function update(UpdateProductAttributeRequest $request, ProductAttribute $productAttribute): ProductAttributeResource + 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'] ?? []; @@ -76,10 +84,20 @@ class ProductAttributeController extends Controller return ProductAttributeResource::make($productAttribute); } - public function destroy(ProductAttribute $productAttribute): Response + 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; + } } diff --git a/app/Domains/Catalog/Controllers/ProductController.php b/app/Domains/Catalog/Controllers/ProductController.php index 6236d3c..59c9918 100644 --- a/app/Domains/Catalog/Controllers/ProductController.php +++ b/app/Domains/Catalog/Controllers/ProductController.php @@ -6,40 +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 { - $product = Product::query()->create($request->validated()); + $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 { + $producto = $this->resolveScopedProduct($tenant, $producto); + return ProductResource::make($producto); } - public function update(UpdateProductRequest $request, Product $producto): ProductResource + 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(Product $producto): Response + 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; + } } diff --git a/app/Domains/Catalog/Requests/StoreBrandRequest.php b/app/Domains/Catalog/Requests/StoreBrandRequest.php index 3f87c5b..5122e77 100644 --- a/app/Domains/Catalog/Requests/StoreBrandRequest.php +++ b/app/Domains/Catalog/Requests/StoreBrandRequest.php @@ -17,7 +17,6 @@ class StoreBrandRequest extends FormRequest public function rules(): array { return [ - 'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'], 'nombre' => ['required', 'string', 'max:255'], 'descripcion' => ['nullable', 'string'], ]; diff --git a/app/Domains/Catalog/Requests/StoreCategoryRequest.php b/app/Domains/Catalog/Requests/StoreCategoryRequest.php index 77c4b97..da5d250 100644 --- a/app/Domains/Catalog/Requests/StoreCategoryRequest.php +++ b/app/Domains/Catalog/Requests/StoreCategoryRequest.php @@ -17,7 +17,6 @@ class StoreCategoryRequest extends FormRequest public function rules(): array { return [ - 'tenant_code' => ['required_with:categoria_id', 'string', 'exists:tenants,codigo'], 'categoria_id' => ['nullable', 'integer', 'exists:categorias,id'], 'nombre' => ['required', 'string', 'max:255'], ]; diff --git a/app/Domains/Catalog/Requests/StoreProductAttributeRequest.php b/app/Domains/Catalog/Requests/StoreProductAttributeRequest.php index 5a45bed..9737901 100644 --- a/app/Domains/Catalog/Requests/StoreProductAttributeRequest.php +++ b/app/Domains/Catalog/Requests/StoreProductAttributeRequest.php @@ -20,13 +20,12 @@ class StoreProductAttributeRequest extends FormRequest public function rules(): array { return [ - 'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'], 'codigo' => [ 'required', 'string', 'max:255', Rule::unique('productos_attributes', 'codigo')->where( - fn ($query) => $query->where('tenant_codigo', $this->input('tenant_codigo')) + fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) ), ], 'nombre' => ['required', 'string', 'max:255'], diff --git a/app/Domains/Catalog/Requests/StoreProductRequest.php b/app/Domains/Catalog/Requests/StoreProductRequest.php index 72ec014..a01bdb8 100644 --- a/app/Domains/Catalog/Requests/StoreProductRequest.php +++ b/app/Domains/Catalog/Requests/StoreProductRequest.php @@ -18,7 +18,6 @@ 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'], diff --git a/app/Domains/Catalog/Requests/UpdateBrandRequest.php b/app/Domains/Catalog/Requests/UpdateBrandRequest.php index 63b1c4f..aa03dd6 100644 --- a/app/Domains/Catalog/Requests/UpdateBrandRequest.php +++ b/app/Domains/Catalog/Requests/UpdateBrandRequest.php @@ -17,7 +17,6 @@ class UpdateBrandRequest extends FormRequest public function rules(): array { return [ - 'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'], 'nombre' => ['required', 'string', 'max:255'], 'descripcion' => ['nullable', 'string'], ]; diff --git a/app/Domains/Catalog/Requests/UpdateCategoryRequest.php b/app/Domains/Catalog/Requests/UpdateCategoryRequest.php index 5db12fb..62c677e 100644 --- a/app/Domains/Catalog/Requests/UpdateCategoryRequest.php +++ b/app/Domains/Catalog/Requests/UpdateCategoryRequest.php @@ -22,7 +22,6 @@ class UpdateCategoryRequest extends FormRequest $category = $this->route('categoria'); return [ - 'tenant_code' => ['required', 'string', 'exists:tenants,codigo'], 'categoria_id' => [ 'nullable', 'integer', diff --git a/app/Domains/Catalog/Requests/UpdateProductRequest.php b/app/Domains/Catalog/Requests/UpdateProductRequest.php index ed25e51..f270db8 100644 --- a/app/Domains/Catalog/Requests/UpdateProductRequest.php +++ b/app/Domains/Catalog/Requests/UpdateProductRequest.php @@ -22,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', diff --git a/app/Domains/Catalog/routes/api.php b/app/Domains/Catalog/routes/api.php index 2a181dc..f755c81 100644 --- a/app/Domains/Catalog/routes/api.php +++ b/app/Domains/Catalog/routes/api.php @@ -6,8 +6,10 @@ use App\Domains\Catalog\Controllers\ProductController; use App\Domains\Catalog\Controllers\ProductAttributeController; 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::apiResource('product-attributes', ProductAttributeController::class) - ->parameters(['product-attributes' => 'productAttribute']); +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']); +}); diff --git a/tests/Feature/Catalog/ProductAttributeControllerTest.php b/tests/Feature/Catalog/ProductAttributeControllerTest.php index e797ad7..bfaefb4 100644 --- a/tests/Feature/Catalog/ProductAttributeControllerTest.php +++ b/tests/Feature/Catalog/ProductAttributeControllerTest.php @@ -18,8 +18,7 @@ class ProductAttributeControllerTest extends TestCase 'dominio' => 'acme.com', ]); - $response = $this->postJson('/api/product-attributes', [ - 'tenant_codigo' => 'acme', + $response = $this->postJson('/api/tenants/acme/product-attributes', [ 'codigo' => 'color', 'nombre' => 'Color', 'is_required' => true, @@ -69,8 +68,7 @@ class ProductAttributeControllerTest extends TestCase 'dominio' => 'acme.com', ]); - $response = $this->postJson('/api/product-attributes', [ - 'tenant_codigo' => 'acme', + $response = $this->postJson('/api/tenants/acme/product-attributes', [ 'codigo' => 'material', 'nombre' => 'Material', 'type' => 'text',