feat: implement tenant management system including database schema, CRUD services, and bootstrap functionality

This commit is contained in:
2026-06-26 12:16:50 -03:00
parent 0dae985230
commit 1f9d876bc3
8 changed files with 394 additions and 5 deletions

View File

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

View File

@@ -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
{

View File

@@ -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:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/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,
];
}
}

View File

@@ -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:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/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,
];
}
}

View File

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

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Domains\Tenant\Services;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class TenantService
{
public function __construct(protected AttachmentService $attachmentService)
{
}
/**
* Create a new tenant and store its logos.
*
* @param array<string, mixed> $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<string, mixed> $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;
});
}
}

View File

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

View File

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