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
This commit is contained in:
2026-06-24 15:16:38 -03:00
parent c6e5177df1
commit 1907178e52
13 changed files with 123 additions and 50 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,6 @@ class UpdateCategoryRequest extends FormRequest
$category = $this->route('categoria');
return [
'tenant_code' => ['required', 'string', 'exists:tenants,codigo'],
'categoria_id' => [
'nullable',
'integer',

View File

@@ -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',

View File

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