From 0dae9852304aa42018daf8cf7edd0eae9d1318da Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 26 Jun 2026 11:55:05 -0300 Subject: [PATCH] feat: implement tenant management system including CRUD operations and domain bootstrapping functionality --- .../Tenant/Controllers/TenantController.php | 10 +- .../Controllers/TenantPropController.php | 45 --------- app/Domains/Tenant/Models/Tenant.php | 97 +------------------ app/Domains/Tenant/Models/TenantProp.php | 42 -------- app/Domains/Tenant/Models/TenantPropValue.php | 36 ------- .../Requests/StoreTenantPropRequest.php | 29 ------ .../Tenant/Requests/StoreTenantRequest.php | 2 - .../Requests/UpdateTenantPropRequest.php | 33 ------- .../Tenant/Requests/UpdateTenantRequest.php | 2 - .../Tenant/Resources/TenantPropResource.php | 29 ------ .../Tenant/Resources/TenantResource.php | 24 ----- .../Tenant/Support/TenantPropRules.php | 51 ---------- app/Domains/Tenant/routes/api.php | 3 - ...06_24_000250_create_tenant_props_table.php | 32 ------ ..._000260_create_model_prop_values_table.php | 44 --------- .../Tenant/BootstrapTenantControllerTest.php | 52 ++-------- .../Tenant/TenantPropControllerTest.php | 45 --------- 17 files changed, 9 insertions(+), 567 deletions(-) delete mode 100644 app/Domains/Tenant/Controllers/TenantPropController.php delete mode 100644 app/Domains/Tenant/Models/TenantProp.php delete mode 100644 app/Domains/Tenant/Models/TenantPropValue.php delete mode 100644 app/Domains/Tenant/Requests/StoreTenantPropRequest.php delete mode 100644 app/Domains/Tenant/Requests/UpdateTenantPropRequest.php delete mode 100644 app/Domains/Tenant/Resources/TenantPropResource.php delete mode 100644 app/Domains/Tenant/Support/TenantPropRules.php delete mode 100644 database/migrations/2026_06_24_000250_create_tenant_props_table.php delete mode 100644 database/migrations/2026_06_24_000260_create_model_prop_values_table.php delete mode 100644 tests/Feature/Tenant/TenantPropControllerTest.php diff --git a/app/Domains/Tenant/Controllers/TenantController.php b/app/Domains/Tenant/Controllers/TenantController.php index a0c3c89..7774207 100644 --- a/app/Domains/Tenant/Controllers/TenantController.php +++ b/app/Domains/Tenant/Controllers/TenantController.php @@ -19,10 +19,7 @@ class TenantController extends Controller public function store(StoreTenantRequest $request): JsonResponse { - $validated = $request->validated(); - $props = $validated['props'] ?? []; - - $tenant = Tenant::createWithProps($validated, $props); + $tenant = Tenant::query()->create($request->validated()); return TenantResource::make($tenant)->response()->setStatusCode(201); } @@ -34,10 +31,7 @@ class TenantController extends Controller public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource { - $validated = $request->validated(); - $props = $validated['props'] ?? []; - - $tenant->updateWithProps($validated, $props); + $tenant->update($request->validated()); return TenantResource::make($tenant); } diff --git a/app/Domains/Tenant/Controllers/TenantPropController.php b/app/Domains/Tenant/Controllers/TenantPropController.php deleted file mode 100644 index bddad3b..0000000 --- a/app/Domains/Tenant/Controllers/TenantPropController.php +++ /dev/null @@ -1,45 +0,0 @@ -latest()->get())->response(); - } - - public function store(StoreTenantPropRequest $request): JsonResponse - { - $tenantProp = TenantProp::query()->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/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index 49f70e5..245bfd7 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -8,8 +8,6 @@ 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', @@ -21,27 +19,6 @@ class Tenant extends Model use HasAttachments; use HasFactory; - /** - * @param array $attributes - * @param array $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 */ @@ -49,77 +26,5 @@ class Tenant extends Model { return $this->hasMany(Product::class, 'tenant_codigo', 'codigo'); } - /** - * @return HasMany - */ - 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 $props - */ - public function syncPropValues(array $props): void - { - foreach ($props as $codigo => $value) { - $this->setPropValue((string) $codigo, $value); - } - } - - /** - * @param array $attributes - * @param array $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(); - } } + diff --git a/app/Domains/Tenant/Models/TenantProp.php b/app/Domains/Tenant/Models/TenantProp.php deleted file mode 100644 index 03944a1..0000000 --- a/app/Domains/Tenant/Models/TenantProp.php +++ /dev/null @@ -1,42 +0,0 @@ - - */ - protected function casts(): array - { - return [ - 'is_required' => 'boolean', - 'data_type' => FieldType::class, - ]; - } - - /** - * @return HasMany - */ - public function values(): HasMany - { - return $this->hasMany(TenantPropValue::class, 'tenant_prop_codigo', 'codigo'); - } -} diff --git a/app/Domains/Tenant/Models/TenantPropValue.php b/app/Domains/Tenant/Models/TenantPropValue.php deleted file mode 100644 index 78aea8f..0000000 --- a/app/Domains/Tenant/Models/TenantPropValue.php +++ /dev/null @@ -1,36 +0,0 @@ - - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); - } - - /** - * @return BelongsTo - */ - public function prop(): BelongsTo - { - return $this->belongsTo(TenantProp::class, 'tenant_prop_codigo', 'codigo'); - } -} diff --git a/app/Domains/Tenant/Requests/StoreTenantPropRequest.php b/app/Domains/Tenant/Requests/StoreTenantPropRequest.php deleted file mode 100644 index b318a77..0000000 --- a/app/Domains/Tenant/Requests/StoreTenantPropRequest.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ - public function rules(): array - { - return [ - '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(FieldType::class)], - ]; - } -} diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index 511eafa..5557f81 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -2,7 +2,6 @@ namespace App\Domains\Tenant\Requests; -use App\Domains\Tenant\Support\TenantPropRules; use App\Domains\Tenant\Support\TenantDomainNormalizer; use Closure; use Illuminate\Foundation\Http\FormRequest; @@ -50,7 +49,6 @@ class StoreTenantRequest extends FormRequest 'max:255', Rule::unique('tenants', 'dominio'), ], - ...TenantPropRules::sync(), ]; } } diff --git a/app/Domains/Tenant/Requests/UpdateTenantPropRequest.php b/app/Domains/Tenant/Requests/UpdateTenantPropRequest.php deleted file mode 100644 index 7cddfac..0000000 --- a/app/Domains/Tenant/Requests/UpdateTenantPropRequest.php +++ /dev/null @@ -1,33 +0,0 @@ - - */ - 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)], - 'nombre' => ['required', 'string', 'max:255'], - 'descripcion' => ['nullable', 'string'], - 'is_required' => ['sometimes', 'boolean'], - 'data_type' => ['required', Rule::enum(FieldType::class)], - ]; - } -} diff --git a/app/Domains/Tenant/Requests/UpdateTenantRequest.php b/app/Domains/Tenant/Requests/UpdateTenantRequest.php index 832d0ab..365b98c 100644 --- a/app/Domains/Tenant/Requests/UpdateTenantRequest.php +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -4,7 +4,6 @@ namespace App\Domains\Tenant\Requests; 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 +58,6 @@ class UpdateTenantRequest extends FormRequest 'max:255', Rule::unique('tenants', 'dominio')->ignore($tenant?->id), ], - ...TenantPropRules::sync(), ]; } } diff --git a/app/Domains/Tenant/Resources/TenantPropResource.php b/app/Domains/Tenant/Resources/TenantPropResource.php deleted file mode 100644 index 8610cf1..0000000 --- a/app/Domains/Tenant/Resources/TenantPropResource.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return [ - 'id' => $this->id, - 'codigo' => $this->codigo, - 'nombre' => $this->nombre, - 'descripcion' => $this->descripcion, - 'is_required' => $this->is_required, - 'data_type' => $this->data_type?->value ?? $this->data_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 index 96e66ea..91a027a 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -10,29 +10,6 @@ use Illuminate\Http\Resources\Json\JsonResource; */ class TenantResource extends JsonResource { - /** - * @return array - */ - 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 */ @@ -43,7 +20,6 @@ class TenantResource extends JsonResource 'codigo' => $this->codigo, 'nombre' => $this->nombre, 'dominio' => $this->dominio, - 'props' => $this->collapseProps(), ]; } } diff --git a/app/Domains/Tenant/Support/TenantPropRules.php b/app/Domains/Tenant/Support/TenantPropRules.php deleted file mode 100644 index 99fbe9b..0000000 --- a/app/Domains/Tenant/Support/TenantPropRules.php +++ /dev/null @@ -1,51 +0,0 @@ - - */ - 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), - )); - } -} diff --git a/app/Domains/Tenant/routes/api.php b/app/Domains/Tenant/routes/api.php index 70623bf..69464a8 100644 --- a/app/Domains/Tenant/routes/api.php +++ b/app/Domains/Tenant/routes/api.php @@ -2,12 +2,9 @@ use App\Domains\Tenant\Controllers\BootstrapTenantController; use App\Domains\Tenant\Controllers\TenantController; -use App\Domains\Tenant\Controllers\TenantPropController; use Illuminate\Support\Facades\Route; Route::get('tenants/bootstrap/{dominio}', BootstrapTenantController::class) ->where('dominio', '.*'); Route::apiResource('tenants', TenantController::class); -Route::apiResource('tenant-props', TenantPropController::class) - ->parameters(['tenant-props' => 'tenantProp']); diff --git a/database/migrations/2026_06_24_000250_create_tenant_props_table.php b/database/migrations/2026_06_24_000250_create_tenant_props_table.php deleted file mode 100644 index 70896d2..0000000 --- a/database/migrations/2026_06_24_000250_create_tenant_props_table.php +++ /dev/null @@ -1,32 +0,0 @@ -id(); - $table->string('codigo')->unique(); - $table->string('nombre'); - $table->text('descripcion')->nullable(); - $table->boolean('is_required')->default(false); - $table->string('data_type'); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('tenant_props'); - } -}; diff --git a/database/migrations/2026_06_24_000260_create_model_prop_values_table.php b/database/migrations/2026_06_24_000260_create_model_prop_values_table.php deleted file mode 100644 index c221d6c..0000000 --- a/database/migrations/2026_06_24_000260_create_model_prop_values_table.php +++ /dev/null @@ -1,44 +0,0 @@ -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'); - } -}; diff --git a/tests/Feature/Tenant/BootstrapTenantControllerTest.php b/tests/Feature/Tenant/BootstrapTenantControllerTest.php index 8a5e069..d29f2ef 100644 --- a/tests/Feature/Tenant/BootstrapTenantControllerTest.php +++ b/tests/Feature/Tenant/BootstrapTenantControllerTest.php @@ -3,7 +3,6 @@ namespace Tests\Feature\Tenant; use App\Domains\Tenant\Models\Tenant; -use App\Domains\Tenant\Models\TenantProp; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -11,7 +10,7 @@ class BootstrapTenantControllerTest extends TestCase { use RefreshDatabase; - public function test_it_bootstraps_a_tenant_by_domain_and_includes_props(): void + public function test_it_bootstraps_a_tenant_by_domain(): void { $tenant = Tenant::create([ 'codigo' => 'acme', @@ -19,23 +18,14 @@ class BootstrapTenantControllerTest extends TestCase 'dominio' => 'acme.com', ]); - TenantProp::create([ - 'codigo' => 'primary_color', - 'nombre' => 'Primary Color', - 'descripcion' => 'Brand color', - 'is_required' => false, - 'data_type' => 'string', - ]); - - $tenant->setPropValue('primary_color', 'blue'); - $response = $this->getJson('/api/tenants/bootstrap/acme.com'); $response ->assertOk() ->assertJsonPath('data.codigo', 'acme') - ->assertJsonPath('data.dominio', 'acme.com') - ->assertJsonPath('data.props.primary_color', 'blue'); + ->assertJsonPath('data.dominio', 'acme.com'); + + $this->assertArrayNotHasKey('props', $response->json('data')); } public function test_it_bootstraps_a_tenant_from_a_full_url(): void @@ -65,33 +55,15 @@ 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('data.dominio', 'acme.com') - ->assertJsonPath('data.props.primary_color', 'blue'); - - $this->assertDatabaseHas('tenant_prop_values', [ - 'tenant_codigo' => 'acme', - 'tenant_prop_codigo' => 'primary_color', - 'value' => 'blue', - ]); + ->assertJsonPath('data.dominio', 'acme.com'); $secondResponse = $this->postJson('/api/tenants', [ 'codigo' => 'globex', @@ -106,14 +78,6 @@ 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', @@ -130,16 +94,12 @@ class BootstrapTenantControllerTest extends TestCase 'codigo' => 'acme', 'nombre' => 'Acme Updated', 'dominio' => 'https://ACME.com:443/admin', - 'props' => [ - 'primary_color' => 'green', - ], ]); $successfulResponse ->assertOk() ->assertJsonPath('data.nombre', 'Acme Updated') - ->assertJsonPath('data.dominio', 'acme.com') - ->assertJsonPath('data.props.primary_color', 'green'); + ->assertJsonPath('data.dominio', 'acme.com'); $failingResponse = $this->putJson("/api/tenants/{$otherTenant->id}", [ 'codigo' => 'globex', diff --git a/tests/Feature/Tenant/TenantPropControllerTest.php b/tests/Feature/Tenant/TenantPropControllerTest.php deleted file mode 100644 index 0515016..0000000 --- a/tests/Feature/Tenant/TenantPropControllerTest.php +++ /dev/null @@ -1,45 +0,0 @@ -postJson('/api/tenant-props', [ - 'codigo' => 'primary_color', - 'nombre' => 'Primary Color', - 'descripcion' => 'Brand color', - 'is_required' => false, - 'data_type' => 'string', - ]); - - $response - ->assertCreated() - ->assertJsonPath('data.codigo', 'primary_color') - ->assertJsonPath('data.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']); - } -}