From 1f9d876bc3f77baef208ea9af48d1e455bd37be8 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 26 Jun 2026 12:16:50 -0300 Subject: [PATCH] feat: implement tenant management system including database schema, CRUD services, and bootstrap functionality --- .../Tenant/Controllers/TenantController.php | 9 +- app/Domains/Tenant/Models/Tenant.php | 6 + .../Tenant/Requests/StoreTenantRequest.php | 54 ++++++ .../Tenant/Requests/UpdateTenantRequest.php | 54 ++++++ .../Tenant/Resources/TenantResource.php | 6 + app/Domains/Tenant/Services/TenantService.php | 105 ++++++++++++ ...2026_06_18_135000_create_tenants_table.php | 6 + .../Tenant/BootstrapTenantControllerTest.php | 159 +++++++++++++++++- 8 files changed, 394 insertions(+), 5 deletions(-) create mode 100644 app/Domains/Tenant/Services/TenantService.php diff --git a/app/Domains/Tenant/Controllers/TenantController.php b/app/Domains/Tenant/Controllers/TenantController.php index 7774207..0a1d8bc 100644 --- a/app/Domains/Tenant/Controllers/TenantController.php +++ b/app/Domains/Tenant/Controllers/TenantController.php @@ -6,12 +6,17 @@ use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Requests\StoreTenantRequest; use App\Domains\Tenant\Requests\UpdateTenantRequest; use App\Domains\Tenant\Resources\TenantResource; +use App\Domains\Tenant\Services\TenantService; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; class TenantController extends Controller { + public function __construct(protected TenantService $tenantService) + { + } + public function index(): JsonResponse { return TenantResource::collection(Tenant::query()->latest()->get())->response(); @@ -19,7 +24,7 @@ class TenantController extends Controller public function store(StoreTenantRequest $request): JsonResponse { - $tenant = Tenant::query()->create($request->validated()); + $tenant = $this->tenantService->create($request->validated()); return TenantResource::make($tenant)->response()->setStatusCode(201); } @@ -31,7 +36,7 @@ class TenantController extends Controller public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource { - $tenant->update($request->validated()); + $tenant = $this->tenantService->update($tenant, $request->validated()); return TenantResource::make($tenant); } diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index 245bfd7..7e12dc8 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -13,6 +13,12 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'codigo', 'nombre', 'dominio', + 'primary_color', + 'secondary_color', + 'danger_color', + 'header_footer_bg_color', + 'header_logo', + 'footer_logo', ])] class Tenant extends Model { diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index 5557f81..734a822 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -5,6 +5,8 @@ namespace App\Domains\Tenant\Requests; use App\Domains\Tenant\Support\TenantDomainNormalizer; use Closure; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; class StoreTenantRequest extends FormRequest @@ -34,6 +36,52 @@ class StoreTenantRequest extends FormRequest */ public function rules(): array { + $logoRule = [ + 'nullable', + static function (string $attribute, mixed $value, Closure $fail): void { + if ($value instanceof UploadedFile) { + $mime = $value->getMimeType(); + if (! str_starts_with($mime, 'image/')) { + $fail("The {$attribute} must be a valid image or SVG."); + } + return; + } + + if (is_string($value)) { + if (Str::isUuid($value)) { + return; + } + + try { + $payload = trim($value); + $declaredMimeType = null; + if (preg_match('/^data:(?[-\w.+\/]+);base64,(?.+)$/s', $payload, $matches) === 1) { + $declaredMimeType = strtolower($matches['mime']); + $payload = $matches['data']; + } + $decoded = base64_decode(preg_replace('/\s+/', '', $payload), true); + if ($decoded === false || $decoded === '') { + $fail("The {$attribute} must be a valid base64 image or SVG."); + return; + } + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $mime = $finfo->buffer($decoded); + if (! $mime && $declaredMimeType) { + $mime = $declaredMimeType; + } + if (! $mime || ! str_starts_with($mime, 'image/')) { + $fail("The {$attribute} must be a valid image or SVG."); + } + } catch (\Throwable) { + $fail("The {$attribute} must be a valid image or SVG."); + } + return; + } + + $fail("The {$attribute} must be a valid file or base64 string."); + }, + ]; + return [ 'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')], 'nombre' => ['required', 'string', 'max:255'], @@ -49,6 +97,12 @@ class StoreTenantRequest extends FormRequest 'max:255', Rule::unique('tenants', 'dominio'), ], + 'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'], + 'secondary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'], + 'danger_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'], + 'header_footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'], + 'header_logo' => $logoRule, + 'footer_logo' => $logoRule, ]; } } diff --git a/app/Domains/Tenant/Requests/UpdateTenantRequest.php b/app/Domains/Tenant/Requests/UpdateTenantRequest.php index 365b98c..ab4fbc0 100644 --- a/app/Domains/Tenant/Requests/UpdateTenantRequest.php +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -6,6 +6,8 @@ use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Support\TenantDomainNormalizer; use Closure; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; class UpdateTenantRequest extends FormRequest @@ -38,6 +40,52 @@ class UpdateTenantRequest extends FormRequest /** @var Tenant|null $tenant */ $tenant = $this->route('tenant'); + $logoRule = [ + 'nullable', + static function (string $attribute, mixed $value, Closure $fail): void { + if ($value instanceof UploadedFile) { + $mime = $value->getMimeType(); + if (! str_starts_with($mime, 'image/')) { + $fail("The {$attribute} must be a valid image or SVG."); + } + return; + } + + if (is_string($value)) { + if (Str::isUuid($value)) { + return; + } + + try { + $payload = trim($value); + $declaredMimeType = null; + if (preg_match('/^data:(?[-\w.+\/]+);base64,(?.+)$/s', $payload, $matches) === 1) { + $declaredMimeType = strtolower($matches['mime']); + $payload = $matches['data']; + } + $decoded = base64_decode(preg_replace('/\s+/', '', $payload), true); + if ($decoded === false || $decoded === '') { + $fail("The {$attribute} must be a valid base64 image or SVG."); + return; + } + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $mime = $finfo->buffer($decoded); + if (! $mime && $declaredMimeType) { + $mime = $declaredMimeType; + } + if (! $mime || ! str_starts_with($mime, 'image/')) { + $fail("The {$attribute} must be a valid image or SVG."); + } + } catch (\Throwable) { + $fail("The {$attribute} must be a valid image or SVG."); + } + return; + } + + $fail("The {$attribute} must be a valid file or base64 string."); + }, + ]; + return [ 'codigo' => [ 'required', @@ -58,6 +106,12 @@ class UpdateTenantRequest extends FormRequest 'max:255', Rule::unique('tenants', 'dominio')->ignore($tenant?->id), ], + 'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'], + 'secondary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'], + 'danger_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'], + 'header_footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'], + 'header_logo' => $logoRule, + 'footer_logo' => $logoRule, ]; } } diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index 91a027a..6b06cb4 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -20,6 +20,12 @@ class TenantResource extends JsonResource 'codigo' => $this->codigo, 'nombre' => $this->nombre, 'dominio' => $this->dominio, + 'primary_color' => $this->primary_color, + 'secondary_color' => $this->secondary_color, + 'danger_color' => $this->danger_color, + 'header_footer_bg_color' => $this->header_footer_bg_color, + 'header_logo' => $this->header_logo, + 'footer_logo' => $this->footer_logo, ]; } } diff --git a/app/Domains/Tenant/Services/TenantService.php b/app/Domains/Tenant/Services/TenantService.php new file mode 100644 index 0000000..573770f --- /dev/null +++ b/app/Domains/Tenant/Services/TenantService.php @@ -0,0 +1,105 @@ + $data + * @return Tenant + */ + public function create(array $data): Tenant + { + return DB::transaction(function () use ($data): Tenant { + $headerLogo = $data['header_logo'] ?? null; + $footerLogo = $data['footer_logo'] ?? null; + + unset($data['header_logo'], $data['footer_logo']); + + /** @var Tenant $tenant */ + $tenant = Tenant::query()->create($data); + + if ($headerLogo) { + $attachment = $this->attachmentService->store($headerLogo, 'tenants'); + $tenant->header_logo = $attachment->key; + $tenant->attachments()->attach($attachment->id); + } + + if ($footerLogo) { + $attachment = $this->attachmentService->store($footerLogo, 'tenants'); + $tenant->footer_logo = $attachment->key; + $tenant->attachments()->attach($attachment->id); + } + + if ($headerLogo || $footerLogo) { + $tenant->save(); + } + + return $tenant; + }); + } + + /** + * Update an existing tenant and store new logos if uploaded. + * + * @param Tenant $tenant + * @param array $data + * @return Tenant + */ + public function update(Tenant $tenant, array $data): Tenant + { + return DB::transaction(function () use ($tenant, $data): Tenant { + $hasHeaderLogoKey = array_key_exists('header_logo', $data); + $hasFooterLogoKey = array_key_exists('footer_logo', $data); + $headerLogo = $data['header_logo'] ?? null; + $footerLogo = $data['footer_logo'] ?? null; + + unset($data['header_logo'], $data['footer_logo']); + + $tenant->fill($data); + + if ($hasHeaderLogoKey) { + if ($headerLogo) { + if (! Str::isUuid($headerLogo)) { + $attachment = $this->attachmentService->store($headerLogo, 'tenants'); + $tenant->header_logo = $attachment->key; + $tenant->attachments()->attach($attachment->id); + } else { + $tenant->header_logo = $headerLogo; + } + } else { + $tenant->header_logo = null; + } + } + + if ($hasFooterLogoKey) { + if ($footerLogo) { + if (! Str::isUuid($footerLogo)) { + $attachment = $this->attachmentService->store($footerLogo, 'tenants'); + $tenant->footer_logo = $attachment->key; + $tenant->attachments()->attach($attachment->id); + } else { + $tenant->footer_logo = $footerLogo; + } + } else { + $tenant->footer_logo = null; + } + } + + $tenant->save(); + + return $tenant; + }); + } +} diff --git a/database/migrations/2026_06_18_135000_create_tenants_table.php b/database/migrations/2026_06_18_135000_create_tenants_table.php index 4445d5e..32adda3 100644 --- a/database/migrations/2026_06_18_135000_create_tenants_table.php +++ b/database/migrations/2026_06_18_135000_create_tenants_table.php @@ -16,6 +16,12 @@ return new class extends Migration $table->string('codigo')->unique(); $table->string('nombre'); $table->string('dominio')->nullable(); + $table->string('primary_color')->nullable(); + $table->string('secondary_color')->nullable(); + $table->string('danger_color')->nullable(); + $table->string('header_footer_bg_color')->nullable(); + $table->string('header_logo')->nullable(); + $table->string('footer_logo')->nullable(); $table->timestamps(); }); } diff --git a/tests/Feature/Tenant/BootstrapTenantControllerTest.php b/tests/Feature/Tenant/BootstrapTenantControllerTest.php index d29f2ef..1a16989 100644 --- a/tests/Feature/Tenant/BootstrapTenantControllerTest.php +++ b/tests/Feature/Tenant/BootstrapTenantControllerTest.php @@ -4,6 +4,9 @@ namespace Tests\Feature\Tenant; use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Tests\TestCase; class BootstrapTenantControllerTest extends TestCase @@ -16,6 +19,12 @@ class BootstrapTenantControllerTest extends TestCase 'codigo' => 'acme', 'nombre' => 'Acme', 'dominio' => 'acme.com', + 'primary_color' => '#ff0000', + 'secondary_color' => '#00ff00', + 'danger_color' => '#0000ff', + 'header_footer_bg_color' => '#ffffff', + 'header_logo' => 'logo_header.png', + 'footer_logo' => 'logo_footer.png', ]); $response = $this->getJson('/api/tenants/bootstrap/acme.com'); @@ -23,7 +32,13 @@ class BootstrapTenantControllerTest extends TestCase $response ->assertOk() ->assertJsonPath('data.codigo', 'acme') - ->assertJsonPath('data.dominio', 'acme.com'); + ->assertJsonPath('data.dominio', 'acme.com') + ->assertJsonPath('data.primary_color', '#ff0000') + ->assertJsonPath('data.secondary_color', '#00ff00') + ->assertJsonPath('data.danger_color', '#0000ff') + ->assertJsonPath('data.header_footer_bg_color', '#ffffff') + ->assertJsonPath('data.header_logo', 'logo_header.png') + ->assertJsonPath('data.footer_logo', 'logo_footer.png'); $this->assertArrayNotHasKey('props', $response->json('data')); } @@ -55,15 +70,43 @@ class BootstrapTenantControllerTest extends TestCase public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void { + $base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + $firstResponse = $this->postJson('/api/tenants', [ 'codigo' => 'acme', 'nombre' => 'Acme', 'dominio' => 'https://ACME.com/path', + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#333333', + 'header_footer_bg_color' => '#444444', + 'header_logo' => $base64Image, + 'footer_logo' => $base64Image, ]); $firstResponse ->assertCreated() - ->assertJsonPath('data.dominio', 'acme.com'); + ->assertJsonPath('data.dominio', 'acme.com') + ->assertJsonPath('data.primary_color', '#111111') + ->assertJsonPath('data.secondary_color', '#222222') + ->assertJsonPath('data.danger_color', '#333333') + ->assertJsonPath('data.header_footer_bg_color', '#444444'); + + $headerUuid = $firstResponse->json('data.header_logo'); + $footerUuid = $firstResponse->json('data.footer_logo'); + + $this->assertTrue(Str::isUuid($headerUuid)); + $this->assertTrue(Str::isUuid($footerUuid)); + + $this->assertDatabaseHas('tenants', [ + 'codigo' => 'acme', + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#333333', + 'header_footer_bg_color' => '#444444', + 'header_logo' => $headerUuid, + 'footer_logo' => $footerUuid, + ]); $secondResponse = $this->postJson('/api/tenants', [ 'codigo' => 'globex', @@ -90,16 +133,41 @@ class BootstrapTenantControllerTest extends TestCase 'dominio' => 'globex.com', ]); + $hdrUuid = (string) Str::uuid(); + $ftrUuid = (string) Str::uuid(); + $successfulResponse = $this->putJson("/api/tenants/{$tenant->id}", [ 'codigo' => 'acme', 'nombre' => 'Acme Updated', 'dominio' => 'https://ACME.com:443/admin', + 'primary_color' => '#555555', + 'secondary_color' => '#666666', + 'danger_color' => '#777777', + 'header_footer_bg_color' => '#888888', + 'header_logo' => $hdrUuid, + 'footer_logo' => $ftrUuid, ]); $successfulResponse ->assertOk() ->assertJsonPath('data.nombre', 'Acme Updated') - ->assertJsonPath('data.dominio', 'acme.com'); + ->assertJsonPath('data.dominio', 'acme.com') + ->assertJsonPath('data.primary_color', '#555555') + ->assertJsonPath('data.secondary_color', '#666666') + ->assertJsonPath('data.danger_color', '#777777') + ->assertJsonPath('data.header_footer_bg_color', '#888888') + ->assertJsonPath('data.header_logo', $hdrUuid) + ->assertJsonPath('data.footer_logo', $ftrUuid); + + $this->assertDatabaseHas('tenants', [ + 'id' => $tenant->id, + 'primary_color' => '#555555', + 'secondary_color' => '#666666', + 'danger_color' => '#777777', + 'header_footer_bg_color' => '#888888', + 'header_logo' => $hdrUuid, + 'footer_logo' => $ftrUuid, + ]); $failingResponse = $this->putJson("/api/tenants/{$otherTenant->id}", [ 'codigo' => 'globex', @@ -111,4 +179,89 @@ class BootstrapTenantControllerTest extends TestCase ->assertUnprocessable() ->assertJsonValidationErrors(['dominio']); } + + public function test_it_validates_aesthetic_colors(): void + { + $response = $this->postJson('/api/tenants', [ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'primary_color' => 'invalid-color', + ]); + + $response->assertJsonValidationErrors(['primary_color']); + + $response2 = $this->postJson('/api/tenants', [ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'primary_color' => '#12345', + ]); + + $response2->assertJsonValidationErrors(['primary_color']); + } + + public function test_it_validates_logo_must_be_image_or_svg(): void + { + Storage::fake('s3'); + + $response = $this->postJson('/api/tenants', [ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'header_logo' => UploadedFile::fake()->create('document.pdf', 10, 'application/pdf'), + ]); + + $response->assertJsonValidationErrors(['header_logo']); + + $response2 = $this->postJson('/api/tenants', [ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'header_logo' => 'data:application/pdf;base64,JVBERi0xLjQKJdcfqksKMSAwIG9iagogIDw8IC9UeXBlIC9DYXRhbG9nCiAgICAvUGFnZXMgMiAwIFI...', + ]); + + $response2->assertJsonValidationErrors(['header_logo']); + } + + public function test_it_stores_uploaded_file_logos_in_tenants_directory(): void + { + Storage::fake('s3'); + + $header = UploadedFile::fake()->image('header.png'); + $footer = UploadedFile::fake()->image('footer.svg', 100, 100); + + $response = $this->postJson('/api/tenants', [ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'header_logo' => $header, + 'footer_logo' => $footer, + ]); + + $response->assertCreated(); + + $headerUuid = $response->json('data.header_logo'); + $footerUuid = $response->json('data.footer_logo'); + + $this->assertTrue(Str::isUuid($headerUuid)); + $this->assertTrue(Str::isUuid($footerUuid)); + + $this->assertDatabaseHas('attachments', ['key' => $headerUuid]); + $this->assertDatabaseHas('attachments', ['key' => $footerUuid]); + } + + public function test_it_stores_base64_logos_in_tenants_directory(): void + { + Storage::fake('s3'); + + $base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + $response = $this->postJson('/api/tenants', [ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'header_logo' => $base64Image, + ]); + + $response->assertCreated(); + + $headerUuid = $response->json('data.header_logo'); + $this->assertTrue(Str::isUuid($headerUuid)); + $this->assertDatabaseHas('attachments', ['key' => $headerUuid]); + } }