diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..011848b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# Project Conventions + +## Architecture + +This project uses a domain-oriented structure under `app/Domains`. + +Each domain must live in: + +```text +app/Domains// +``` + +Supported folders inside each domain: + +```text +app/Domains// +├── Models/ +├── Controllers/ +├── Requests/ +├── Resources/ +├── Services/ +├── Policies/ +├── Exceptions/ +└── routes/ +``` + +## Routing + +- Root API routes live in `routes/api.php`. +- `routes/api.php` should act as an aggregator only. +- Each domain defines its own routes in `app/Domains//routes/api.php`. + +## Namespaces + +Use this namespace pattern: + +```php +App\Domains\\Models +App\Domains\\Controllers +App\Domains\\Requests +App\Domains\\Resources +App\Domains\\Services +App\Domains\\Policies +App\Domains\\Exceptions +``` + +## Responsibilities + +- `Models/`: Eloquent models and model relationships. +- `Controllers/`: HTTP entrypoints only. +- `Requests/`: validation and request authorization with `FormRequest`. +- `Resources/`: API response transformation with `JsonResource`. +- `Services/`: business logic and application workflows. +- `Policies/`: authorization rules. +- `Exceptions/`: domain-specific exceptions. +- `routes/`: domain route definitions. + +## Controller Rules + +- Controllers should stay thin. +- Do not validate inline in controllers when a `FormRequest` can be used. +- Do not place business logic in controllers. +- Prefer returning `JsonResource` or resource collections instead of raw models. + +## Model Rules + +- Models must stay inside their domain. +- Cross-domain relations are allowed when the dependency is explicit and necessary. +- Preserve existing table names and key mappings when the database uses legacy names. + +## Naming Rules + +- Domains use singular English names such as `Tenant` and `Product`. +- Namespace, folder, and class names must stay aligned. +- Route files should be named `api.php`. + +## Current Domains + +- `Tenant` +- `Product` + +## Notes For Future Changes + +- New features should be added inside the corresponding domain first. +- If a concern does not belong to an existing domain, create a new domain under `app/Domains`. +- Avoid placing new business code in `app/Http/Controllers` or `app/Models`. diff --git a/app/Domains/Product/Controllers/ProductController.php b/app/Domains/Product/Controllers/ProductController.php new file mode 100644 index 0000000..2bf9d27 --- /dev/null +++ b/app/Domains/Product/Controllers/ProductController.php @@ -0,0 +1,45 @@ +latest()->get())->response(); + } + + public function store(StoreProductRequest $request): JsonResponse + { + $product = Product::create($request->validated()); + + return ProductResource::make($product)->response()->setStatusCode(201); + } + + public function show(Product $producto): ProductResource + { + return ProductResource::make($producto); + } + + public function update(UpdateProductRequest $request, Product $producto): ProductResource + { + $producto->update($request->validated()); + + return ProductResource::make($producto); + } + + public function destroy(Product $producto): Response + { + $producto->delete(); + + return response()->noContent(); + } +} diff --git a/app/Domains/Product/Models/Brand.php b/app/Domains/Product/Models/Brand.php new file mode 100644 index 0000000..d8fb316 --- /dev/null +++ b/app/Domains/Product/Models/Brand.php @@ -0,0 +1,10 @@ + + */ + 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'], + ]; + } +} diff --git a/app/Domains/Product/Requests/UpdateProductRequest.php b/app/Domains/Product/Requests/UpdateProductRequest.php new file mode 100644 index 0000000..48183b2 --- /dev/null +++ b/app/Domains/Product/Requests/UpdateProductRequest.php @@ -0,0 +1,38 @@ + + */ + public function rules(): array + { + /** @var Product|null $product */ + $product = $this->route('producto'); + + return [ + 'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'], + 'categoria_id' => ['required', 'integer'], + 'slug' => [ + 'required', + 'string', + 'max:255', + Rule::unique('productos', 'slug')->ignore($product?->id), + ], + 'nombre' => ['required', 'string', 'max:255'], + 'descripcion' => ['nullable', 'string'], + 'precio' => ['required', 'numeric', 'min:0'], + ]; + } +} diff --git a/app/Domains/Product/Resources/ProductResource.php b/app/Domains/Product/Resources/ProductResource.php new file mode 100644 index 0000000..d1b24ff --- /dev/null +++ b/app/Domains/Product/Resources/ProductResource.php @@ -0,0 +1,30 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'tenant_codigo' => $this->tenant_codigo, + 'categoria_id' => $this->categoria_id, + 'slug' => $this->slug, + 'nombre' => $this->nombre, + 'descripcion' => $this->descripcion, + 'precio' => $this->precio, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Domains/Product/Services/ProductService.php b/app/Domains/Product/Services/ProductService.php new file mode 100644 index 0000000..c872826 --- /dev/null +++ b/app/Domains/Product/Services/ProductService.php @@ -0,0 +1,8 @@ +latest()->get())->response(); + } + + public function store(StoreTenantRequest $request): JsonResponse + { + $tenant = Tenant::create($request->validated()); + + return TenantResource::make($tenant)->response()->setStatusCode(201); + } + + public function show(Tenant $tenant): TenantResource + { + return TenantResource::make($tenant); + } + + public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource + { + $tenant->update($request->validated()); + + return TenantResource::make($tenant); + } + + public function destroy(Tenant $tenant): Response + { + $tenant->delete(); + + return response()->noContent(); + } +} diff --git a/app/Domains/Tenant/Controllers/TenantPropController.php b/app/Domains/Tenant/Controllers/TenantPropController.php new file mode 100644 index 0000000..4edd4f8 --- /dev/null +++ b/app/Domains/Tenant/Controllers/TenantPropController.php @@ -0,0 +1,45 @@ +latest()->get())->response(); + } + + public function store(StoreTenantPropRequest $request): JsonResponse + { + $tenantProp = TenantProp::create($request->validated()); + + return TenantPropResource::make($tenantProp)->response()->setStatusCode(201); + } + + public function show(TenantProp $tenantProp): TenantPropResource + { + return TenantPropResource::make($tenantProp); + } + + public function update(UpdateTenantPropRequest $request, TenantProp $tenantProp): TenantPropResource + { + $tenantProp->update($request->validated()); + + return TenantPropResource::make($tenantProp); + } + + public function destroy(TenantProp $tenantProp): Response + { + $tenantProp->delete(); + + return response()->noContent(); + } +} diff --git a/app/Domains/Tenant/Exceptions/.gitkeep b/app/Domains/Tenant/Exceptions/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/Domains/Tenant/Exceptions/.gitkeep @@ -0,0 +1 @@ + diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php new file mode 100644 index 0000000..19eb89f --- /dev/null +++ b/app/Domains/Tenant/Models/Tenant.php @@ -0,0 +1,38 @@ + + */ + public function productos(): HasMany + { + return $this->hasMany(Product::class, 'tenant_codigo', 'codigo'); + } + + /** + * @return BelongsToMany + */ + public function tenantProps(): BelongsToMany + { + return $this->belongsToMany(TenantProp::class, 'tenant_prop_definitions', 'tenant_codigo', 'prop_codigo', 'codigo', 'codigo') + ->withPivot('valor') + ->withTimestamps(); + } +} diff --git a/app/Domains/Tenant/Models/TenantProp.php b/app/Domains/Tenant/Models/TenantProp.php new file mode 100644 index 0000000..012a572 --- /dev/null +++ b/app/Domains/Tenant/Models/TenantProp.php @@ -0,0 +1,27 @@ + + */ + public function tenants(): BelongsToMany + { + return $this->belongsToMany(Tenant::class, 'tenant_prop_definitions', 'prop_codigo', 'tenant_codigo', 'codigo', 'codigo') + ->withPivot('valor') + ->withTimestamps(); + } +} diff --git a/app/Domains/Tenant/Policies/.gitkeep b/app/Domains/Tenant/Policies/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/Domains/Tenant/Policies/.gitkeep @@ -0,0 +1 @@ + diff --git a/app/Domains/Tenant/Requests/StoreTenantPropRequest.php b/app/Domains/Tenant/Requests/StoreTenantPropRequest.php new file mode 100644 index 0000000..e27de04 --- /dev/null +++ b/app/Domains/Tenant/Requests/StoreTenantPropRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'codigo' => ['required', 'string', 'max:255', Rule::unique('tenant_props', 'codigo')], + 'prop_type' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php new file mode 100644 index 0000000..ffdaf10 --- /dev/null +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -0,0 +1,26 @@ + + */ + public function rules(): array + { + return [ + 'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')], + 'nombre' => ['required', 'string', 'max:255'], + 'dominio' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Domains/Tenant/Requests/UpdateTenantPropRequest.php b/app/Domains/Tenant/Requests/UpdateTenantPropRequest.php new file mode 100644 index 0000000..4271d4c --- /dev/null +++ b/app/Domains/Tenant/Requests/UpdateTenantPropRequest.php @@ -0,0 +1,34 @@ + + */ + public function rules(): array + { + /** @var TenantProp|null $tenantProp */ + $tenantProp = $this->route('tenantProp'); + + return [ + 'codigo' => [ + 'required', + 'string', + 'max:255', + Rule::unique('tenant_props', 'codigo')->ignore($tenantProp?->id), + ], + 'prop_type' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Domains/Tenant/Requests/UpdateTenantRequest.php b/app/Domains/Tenant/Requests/UpdateTenantRequest.php new file mode 100644 index 0000000..d8a45b1 --- /dev/null +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -0,0 +1,35 @@ + + */ + public function rules(): array + { + /** @var Tenant|null $tenant */ + $tenant = $this->route('tenant'); + + return [ + 'codigo' => [ + 'required', + 'string', + 'max:255', + Rule::unique('tenants', 'codigo')->ignore($tenant?->id), + ], + 'nombre' => ['required', 'string', 'max:255'], + 'dominio' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Domains/Tenant/Resources/TenantPropResource.php b/app/Domains/Tenant/Resources/TenantPropResource.php new file mode 100644 index 0000000..a7bc284 --- /dev/null +++ b/app/Domains/Tenant/Resources/TenantPropResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'codigo' => $this->codigo, + 'prop_type' => $this->prop_type, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php new file mode 100644 index 0000000..6327faf --- /dev/null +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -0,0 +1,27 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'codigo' => $this->codigo, + 'nombre' => $this->nombre, + 'dominio' => $this->dominio, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Domains/Tenant/Services/.gitkeep b/app/Domains/Tenant/Services/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/Domains/Tenant/Services/.gitkeep @@ -0,0 +1 @@ + diff --git a/app/Domains/Tenant/routes/api.php b/app/Domains/Tenant/routes/api.php new file mode 100644 index 0000000..4374bc9 --- /dev/null +++ b/app/Domains/Tenant/routes/api.php @@ -0,0 +1,9 @@ +parameters(['tenant-props' => 'tenantProp']); diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php deleted file mode 100644 index 1bf6371..0000000 --- a/app/Http/Controllers/ProductController.php +++ /dev/null @@ -1,67 +0,0 @@ -json(Producto::query()->latest()->get()); - } - - public function store(Request $request): JsonResponse - { - $validated = $request->validate($this->rules()); - - $producto = Producto::create($validated); - - return response()->json($producto, 201); - } - - public function show(Producto $producto): JsonResponse - { - return response()->json($producto); - } - - public function update(Request $request, Producto $producto): JsonResponse - { - $validated = $request->validate($this->rules($producto->id)); - - $producto->update($validated); - - return response()->json($producto); - } - - public function destroy(Producto $producto): Response - { - $producto->delete(); - - return response()->noContent(); - } - - /** - * @return array - */ - protected function rules(?int $productoId = null): array - { - return [ - 'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'], - 'categoria_id' => ['required', 'integer'], - 'slug' => [ - 'required', - 'string', - 'max:255', - Rule::unique('productos', 'slug')->ignore($productoId), - ], - 'nombre' => ['required', 'string', 'max:255'], - 'descripcion' => ['nullable', 'string'], - 'precio' => ['required', 'numeric', 'min:0'], - ]; - } -} diff --git a/app/Http/Controllers/TenantController.php b/app/Http/Controllers/TenantController.php deleted file mode 100644 index 43cde02..0000000 --- a/app/Http/Controllers/TenantController.php +++ /dev/null @@ -1,64 +0,0 @@ -json(Tenant::query()->latest()->get()); - } - - public function store(Request $request): JsonResponse - { - $validated = $request->validate($this->rules()); - - $tenant = Tenant::create($validated); - - return response()->json($tenant, 201); - } - - public function show(Tenant $tenant): JsonResponse - { - return response()->json($tenant); - } - - public function update(Request $request, Tenant $tenant): JsonResponse - { - $validated = $request->validate($this->rules($tenant->id)); - - $tenant->update($validated); - - return response()->json($tenant); - } - - public function destroy(Tenant $tenant): Response - { - $tenant->delete(); - - return response()->noContent(); - } - - /** - * @return array - */ - protected function rules(?int $tenantId = null): array - { - return [ - 'codigo' => [ - 'required', - 'string', - 'max:255', - Rule::unique('tenants', 'codigo')->ignore($tenantId), - ], - 'nombre' => ['required', 'string', 'max:255'], - 'dominio' => ['nullable', 'string', 'max:255'], - ]; - } -} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php deleted file mode 100644 index c03aacf..0000000 --- a/app/Models/Tenant.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ - public function productos(): HasMany - { - return $this->hasMany(Producto::class, 'tenant_codigo', 'codigo'); - } -} diff --git a/database/migrations/2026_06_19_010000_create_tenant_props_table.php b/database/migrations/2026_06_19_010000_create_tenant_props_table.php new file mode 100644 index 0000000..a68eac8 --- /dev/null +++ b/database/migrations/2026_06_19_010000_create_tenant_props_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('codigo')->unique(); + $table->string('prop_type'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tenant_props'); + } +}; diff --git a/database/migrations/2026_06_19_010100_create_tenant_prop_definitions_table.php b/database/migrations/2026_06_19_010100_create_tenant_prop_definitions_table.php new file mode 100644 index 0000000..20e9b67 --- /dev/null +++ b/database/migrations/2026_06_19_010100_create_tenant_prop_definitions_table.php @@ -0,0 +1,44 @@ +id(); + $table->string('tenant_codigo'); + $table->string('prop_codigo'); + $table->text('valor')->nullable(); + $table->timestamps(); + + $table->foreign('tenant_codigo') + ->references('codigo') + ->on('tenants') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + + $table->foreign('prop_codigo') + ->references('codigo') + ->on('tenant_props') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + + $table->unique(['tenant_codigo', 'prop_codigo']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tenant_prop_definitions'); + } +}; diff --git a/routes/api.php b/routes/api.php index 691f4a5..c133d57 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,7 +1,5 @@ user(); })->middleware('auth:sanctum'); -Route::apiResource('productos', ProductController::class); -Route::apiResource('tenants', TenantController::class); +require __DIR__.'/../app/Domains/Product/routes/api.php'; +require __DIR__.'/../app/Domains/Tenant/routes/api.php';