feat: implement tenant management system including CRUD operations and domain bootstrapping functionality

This commit is contained in:
2026-06-26 11:55:05 -03:00
parent 4c237fb729
commit 0dae985230
17 changed files with 9 additions and 567 deletions

View File

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

View File

@@ -1,45 +0,0 @@
<?php
namespace App\Domains\Tenant\Controllers;
use App\Domains\Tenant\Models\TenantProp;
use App\Domains\Tenant\Requests\StoreTenantPropRequest;
use App\Domains\Tenant\Requests\UpdateTenantPropRequest;
use App\Domains\Tenant\Resources\TenantPropResource;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class TenantPropController extends Controller
{
public function index(): JsonResponse
{
return TenantPropResource::collection(TenantProp::query()->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();
}
}

View File

@@ -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<string, mixed> $attributes
* @param array<string, mixed> $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<Product, $this>
*/
@@ -49,77 +26,5 @@ class Tenant extends Model
{
return $this->hasMany(Product::class, 'tenant_codigo', 'codigo');
}
/**
* @return HasMany<TenantPropValue, $this>
*/
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<string, mixed> $props
*/
public function syncPropValues(array $props): void
{
foreach ($props as $codigo => $value) {
$this->setPropValue((string) $codigo, $value);
}
}
/**
* @param array<string, mixed> $attributes
* @param array<string, mixed> $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();
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Domains\Tenant\Models;
use App\Domains\Shared\Enums\FieldType;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'codigo',
'nombre',
'descripcion',
'is_required',
'data_type',
])]
class TenantProp extends Model
{
use HasFactory;
protected $table = 'tenant_props';
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_required' => 'boolean',
'data_type' => FieldType::class,
];
}
/**
* @return HasMany<TenantPropValue, $this>
*/
public function values(): HasMany
{
return $this->hasMany(TenantPropValue::class, 'tenant_prop_codigo', 'codigo');
}
}

View File

@@ -1,36 +0,0 @@
<?php
namespace App\Domains\Tenant\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'tenant_codigo',
'tenant_prop_codigo',
'value',
])]
class TenantPropValue extends Model
{
use HasFactory;
protected $table = 'tenant_prop_values';
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return BelongsTo<TenantProp, $this>
*/
public function prop(): BelongsTo
{
return $this->belongsTo(TenantProp::class, 'tenant_prop_codigo', 'codigo');
}
}

View File

@@ -1,29 +0,0 @@
<?php
namespace App\Domains\Tenant\Requests;
use App\Domains\Shared\Enums\FieldType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreTenantPropRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
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)],
];
}
}

View File

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

View File

@@ -1,33 +0,0 @@
<?php
namespace App\Domains\Tenant\Requests;
use App\Domains\Tenant\Models\TenantProp;
use App\Domains\Shared\Enums\FieldType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateTenantPropRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
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)],
];
}
}

View File

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

View File

@@ -1,29 +0,0 @@
<?php
namespace App\Domains\Tenant\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Tenant\Models\TenantProp
*/
class TenantPropResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
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,
];
}
}

View File

@@ -10,29 +10,6 @@ use Illuminate\Http\Resources\Json\JsonResource;
*/
class TenantResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
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<string, mixed>
*/
@@ -43,7 +20,6 @@ class TenantResource extends JsonResource
'codigo' => $this->codigo,
'nombre' => $this->nombre,
'dominio' => $this->dominio,
'props' => $this->collapseProps(),
];
}
}

View File

@@ -1,51 +0,0 @@
<?php
namespace App\Domains\Tenant\Support;
use App\Domains\Tenant\Models\TenantProp;
use Closure;
class TenantPropRules
{
/**
* @return array<string, mixed>
*/
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),
));
}
}

View File

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

View File

@@ -1,32 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tenant_props', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -1,44 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tenant_prop_values', function (Blueprint $table) {
$table->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');
}
};

View File

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

View File

@@ -1,45 +0,0 @@
<?php
namespace Tests\Feature\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TenantPropControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_a_tenant_prop(): void
{
$response = $this->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']);
}
}