diff --git a/app/Domains/Tenant/Controllers/TenantController.php b/app/Domains/Tenant/Controllers/TenantController.php index 7774207..a0c3c89 100644 --- a/app/Domains/Tenant/Controllers/TenantController.php +++ b/app/Domains/Tenant/Controllers/TenantController.php @@ -19,7 +19,10 @@ class TenantController extends Controller public function store(StoreTenantRequest $request): JsonResponse { - $tenant = Tenant::query()->create($request->validated()); + $validated = $request->validated(); + $props = $validated['props'] ?? []; + + $tenant = Tenant::createWithProps($validated, $props); return TenantResource::make($tenant)->response()->setStatusCode(201); } @@ -31,7 +34,10 @@ class TenantController extends Controller public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource { - $tenant->update($request->validated()); + $validated = $request->validated(); + $props = $validated['props'] ?? []; + + $tenant->updateWithProps($validated, $props); return TenantResource::make($tenant); } diff --git a/app/Domains/Tenant/Controllers/TenantPropController.php b/app/Domains/Tenant/Controllers/TenantPropController.php new file mode 100644 index 0000000..bddad3b --- /dev/null +++ b/app/Domains/Tenant/Controllers/TenantPropController.php @@ -0,0 +1,45 @@ +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 c06e0ec..a016a30 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -7,6 +7,8 @@ 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', @@ -17,6 +19,27 @@ class Tenant extends Model { 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 */ @@ -24,4 +47,78 @@ 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 new file mode 100644 index 0000000..493edbc --- /dev/null +++ b/app/Domains/Tenant/Models/TenantProp.php @@ -0,0 +1,42 @@ + + */ + protected function casts(): array + { + return [ + 'is_required' => 'boolean', + 'data_type' => TenantPropDataType::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 new file mode 100644 index 0000000..78aea8f --- /dev/null +++ b/app/Domains/Tenant/Models/TenantPropValue.php @@ -0,0 +1,36 @@ + + */ + 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 new file mode 100644 index 0000000..7d50af7 --- /dev/null +++ b/app/Domains/Tenant/Requests/StoreTenantPropRequest.php @@ -0,0 +1,29 @@ + + */ + 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(TenantPropDataType::class)], + ]; + } +} diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index 5557f81..511eafa 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -2,6 +2,7 @@ namespace App\Domains\Tenant\Requests; +use App\Domains\Tenant\Support\TenantPropRules; use App\Domains\Tenant\Support\TenantDomainNormalizer; use Closure; use Illuminate\Foundation\Http\FormRequest; @@ -49,6 +50,7 @@ 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 new file mode 100644 index 0000000..140ec28 --- /dev/null +++ b/app/Domains/Tenant/Requests/UpdateTenantPropRequest.php @@ -0,0 +1,33 @@ + + */ + 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(TenantPropDataType::class)], + ]; + } +} diff --git a/app/Domains/Tenant/Requests/UpdateTenantRequest.php b/app/Domains/Tenant/Requests/UpdateTenantRequest.php index 365b98c..832d0ab 100644 --- a/app/Domains/Tenant/Requests/UpdateTenantRequest.php +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -4,6 +4,7 @@ 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; @@ -58,6 +59,7 @@ 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 new file mode 100644 index 0000000..8610cf1 --- /dev/null +++ b/app/Domains/Tenant/Resources/TenantPropResource.php @@ -0,0 +1,29 @@ + + */ + 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 91a027a..96e66ea 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -10,6 +10,29 @@ 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 */ @@ -20,6 +43,7 @@ class TenantResource extends JsonResource 'codigo' => $this->codigo, 'nombre' => $this->nombre, 'dominio' => $this->dominio, + 'props' => $this->collapseProps(), ]; } } diff --git a/app/Domains/Tenant/Support/TenantPropDataType.php b/app/Domains/Tenant/Support/TenantPropDataType.php new file mode 100644 index 0000000..d2dc5eb --- /dev/null +++ b/app/Domains/Tenant/Support/TenantPropDataType.php @@ -0,0 +1,18 @@ + + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Domains/Tenant/Support/TenantPropRules.php b/app/Domains/Tenant/Support/TenantPropRules.php new file mode 100644 index 0000000..99fbe9b --- /dev/null +++ b/app/Domains/Tenant/Support/TenantPropRules.php @@ -0,0 +1,51 @@ + + */ + 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 69464a8..70623bf 100644 --- a/app/Domains/Tenant/routes/api.php +++ b/app/Domains/Tenant/routes/api.php @@ -2,9 +2,12 @@ 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 new file mode 100644 index 0000000..70896d2 --- /dev/null +++ b/database/migrations/2026_06_24_000250_create_tenant_props_table.php @@ -0,0 +1,32 @@ +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 new file mode 100644 index 0000000..c221d6c --- /dev/null +++ b/database/migrations/2026_06_24_000260_create_model_prop_values_table.php @@ -0,0 +1,44 @@ +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 16e549b..789d1ff 100644 --- a/tests/Feature/Tenant/BootstrapTenantControllerTest.php +++ b/tests/Feature/Tenant/BootstrapTenantControllerTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Tenant; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Tenant\Models\TenantProp; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -10,7 +11,7 @@ class BootstrapTenantControllerTest extends TestCase { use RefreshDatabase; - public function test_it_bootstraps_a_tenant_by_domain(): void + public function test_it_bootstraps_a_tenant_by_domain_and_includes_props(): void { $tenant = Tenant::create([ 'codigo' => 'acme', @@ -18,12 +19,23 @@ 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('codigo', 'acme') - ->assertJsonPath('dominio', 'acme.com'); + ->assertJsonPath('dominio', 'acme.com') + ->assertJsonPath('props.primary_color', 'blue'); } public function test_it_bootstraps_a_tenant_from_a_full_url(): void @@ -53,15 +65,33 @@ 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('dominio', 'acme.com'); + ->assertJsonPath('dominio', 'acme.com') + ->assertJsonPath('props.primary_color', 'blue'); + + $this->assertDatabaseHas('tenant_prop_values', [ + 'tenant_codigo' => 'acme', + 'tenant_prop_codigo' => 'primary_color', + 'value' => 'blue', + ]); $secondResponse = $this->postJson('/api/tenants', [ 'codigo' => 'globex', @@ -76,6 +106,14 @@ 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', @@ -92,12 +130,16 @@ class BootstrapTenantControllerTest extends TestCase 'codigo' => 'acme', 'nombre' => 'Acme Updated', 'dominio' => 'https://ACME.com:443/admin', + 'props' => [ + 'primary_color' => 'green', + ], ]); $successfulResponse ->assertOk() ->assertJsonPath('nombre', 'Acme Updated') - ->assertJsonPath('dominio', 'acme.com'); + ->assertJsonPath('dominio', 'acme.com') + ->assertJsonPath('props.primary_color', 'green'); $failingResponse = $this->putJson("/api/tenants/{$otherTenant->id}", [ 'codigo' => 'globex', diff --git a/tests/Feature/Tenant/TenantPropControllerTest.php b/tests/Feature/Tenant/TenantPropControllerTest.php new file mode 100644 index 0000000..490b628 --- /dev/null +++ b/tests/Feature/Tenant/TenantPropControllerTest.php @@ -0,0 +1,45 @@ +postJson('/api/tenant-props', [ + 'codigo' => 'primary_color', + 'nombre' => 'Primary Color', + 'descripcion' => 'Brand color', + 'is_required' => false, + 'data_type' => 'string', + ]); + + $response + ->assertCreated() + ->assertJsonPath('codigo', 'primary_color') + ->assertJsonPath('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']); + } +}