diff --git a/app/Domains/Attachable/Models/Attachment.php b/app/Domains/Attachable/Models/Attachment.php index c94bf4f..cca3f90 100644 --- a/app/Domains/Attachable/Models/Attachment.php +++ b/app/Domains/Attachable/Models/Attachment.php @@ -48,4 +48,15 @@ class Attachment extends Model { return $this->hasMany(AttachableAttachment::class, 'attachment_id'); } + + /** + * Get the pre-signed temporary S3 URL for this attachment. + */ + public function getTemporaryUrl(int $expiresInMinutes = 10): string + { + return \Illuminate\Support\Facades\Storage::disk('s3')->temporaryUrl( + $this->path, + now()->addMinutes($expiresInMinutes) + ); + } } diff --git a/app/Domains/Tenant/Controllers/BootstrapTenantController.php b/app/Domains/Tenant/Controllers/BootstrapTenantController.php index 164ca1b..f9803b2 100644 --- a/app/Domains/Tenant/Controllers/BootstrapTenantController.php +++ b/app/Domains/Tenant/Controllers/BootstrapTenantController.php @@ -15,7 +15,7 @@ class BootstrapTenantController extends Controller $dominio = $request->validated('dominio'); return TenantResource::make( - Tenant::query()->where('dominio', $dominio)->firstOrFail() + Tenant::query()->with(['headerLogo', 'footerLogo'])->where('dominio', $dominio)->firstOrFail() ); } } diff --git a/app/Domains/Tenant/Controllers/TenantController.php b/app/Domains/Tenant/Controllers/TenantController.php index 0a1d8bc..a6c451d 100644 --- a/app/Domains/Tenant/Controllers/TenantController.php +++ b/app/Domains/Tenant/Controllers/TenantController.php @@ -19,26 +19,26 @@ class TenantController extends Controller public function index(): JsonResponse { - return TenantResource::collection(Tenant::query()->latest()->get())->response(); + return TenantResource::collection(Tenant::query()->with(['headerLogo', 'footerLogo'])->latest()->get())->response(); } public function store(StoreTenantRequest $request): JsonResponse { $tenant = $this->tenantService->create($request->validated()); - return TenantResource::make($tenant)->response()->setStatusCode(201); + return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']))->response()->setStatusCode(201); } public function show(Tenant $tenant): TenantResource { - return TenantResource::make($tenant); + return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo'])); } public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource { $tenant = $this->tenantService->update($tenant, $request->validated()); - return TenantResource::make($tenant); + return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo'])); } public function destroy(Tenant $tenant): Response diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index 7e12dc8..f831704 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -2,11 +2,13 @@ namespace App\Domains\Tenant\Models; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Concerns\HasAttachments; use App\Domains\Catalog\Models\Product; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ @@ -17,14 +19,30 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'secondary_color', 'danger_color', 'header_footer_bg_color', - 'header_logo', - 'footer_logo', + 'header_logo_id', + 'footer_logo_id', ])] class Tenant extends Model { use HasAttachments; use HasFactory; + /** + * @return BelongsTo + */ + public function headerLogo(): BelongsTo + { + return $this->belongsTo(Attachment::class, 'header_logo_id'); + } + + /** + * @return BelongsTo + */ + public function footerLogo(): BelongsTo + { + return $this->belongsTo(Attachment::class, 'footer_logo_id'); + } + /** * @return HasMany */ diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index 6b06cb4..acfb500 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -24,8 +24,8 @@ class TenantResource extends JsonResource '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, + 'header_logo' => $this->headerLogo?->getTemporaryUrl(), + 'footer_logo' => $this->footerLogo?->getTemporaryUrl(), ]; } } diff --git a/app/Domains/Tenant/Services/TenantService.php b/app/Domains/Tenant/Services/TenantService.php index 573770f..0e9e77e 100644 --- a/app/Domains/Tenant/Services/TenantService.php +++ b/app/Domains/Tenant/Services/TenantService.php @@ -31,15 +31,25 @@ class TenantService $tenant = Tenant::query()->create($data); if ($headerLogo) { - $attachment = $this->attachmentService->store($headerLogo, 'tenants'); - $tenant->header_logo = $attachment->key; - $tenant->attachments()->attach($attachment->id); + $attachment = Str::isUuid($headerLogo) + ? \App\Domains\Attachable\Models\Attachment::query()->where('key', $headerLogo)->first() + : $this->attachmentService->store($headerLogo, 'tenants'); + + if ($attachment) { + $tenant->header_logo_id = $attachment->id; + $tenant->attachments()->attach($attachment->id); + } } if ($footerLogo) { - $attachment = $this->attachmentService->store($footerLogo, 'tenants'); - $tenant->footer_logo = $attachment->key; - $tenant->attachments()->attach($attachment->id); + $attachment = Str::isUuid($footerLogo) + ? \App\Domains\Attachable\Models\Attachment::query()->where('key', $footerLogo)->first() + : $this->attachmentService->store($footerLogo, 'tenants'); + + if ($attachment) { + $tenant->footer_logo_id = $attachment->id; + $tenant->attachments()->attach($attachment->id); + } } if ($headerLogo || $footerLogo) { @@ -71,29 +81,35 @@ class TenantService if ($hasHeaderLogoKey) { if ($headerLogo) { - if (! Str::isUuid($headerLogo)) { - $attachment = $this->attachmentService->store($headerLogo, 'tenants'); - $tenant->header_logo = $attachment->key; + $attachment = Str::isUuid($headerLogo) + ? \App\Domains\Attachable\Models\Attachment::query()->where('key', $headerLogo)->first() + : $this->attachmentService->store($headerLogo, 'tenants'); + + if ($attachment) { + $tenant->header_logo_id = $attachment->id; $tenant->attachments()->attach($attachment->id); } else { - $tenant->header_logo = $headerLogo; + $tenant->header_logo_id = null; } } else { - $tenant->header_logo = null; + $tenant->header_logo_id = null; } } if ($hasFooterLogoKey) { if ($footerLogo) { - if (! Str::isUuid($footerLogo)) { - $attachment = $this->attachmentService->store($footerLogo, 'tenants'); - $tenant->footer_logo = $attachment->key; + $attachment = Str::isUuid($footerLogo) + ? \App\Domains\Attachable\Models\Attachment::query()->where('key', $footerLogo)->first() + : $this->attachmentService->store($footerLogo, 'tenants'); + + if ($attachment) { + $tenant->footer_logo_id = $attachment->id; $tenant->attachments()->attach($attachment->id); } else { - $tenant->footer_logo = $footerLogo; + $tenant->footer_logo_id = null; } } else { - $tenant->footer_logo = null; + $tenant->footer_logo_id = null; } } 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 32adda3..05948f5 100644 --- a/database/migrations/2026_06_18_135000_create_tenants_table.php +++ b/database/migrations/2026_06_18_135000_create_tenants_table.php @@ -20,8 +20,8 @@ return new class extends Migration $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->unsignedBigInteger('header_logo_id')->nullable(); + $table->unsignedBigInteger('footer_logo_id')->nullable(); $table->timestamps(); }); } diff --git a/database/migrations/2026_06_25_000500_create_attachments_table.php b/database/migrations/2026_06_25_000500_create_attachments_table.php index 8f32728..ad43dae 100644 --- a/database/migrations/2026_06_25_000500_create_attachments_table.php +++ b/database/migrations/2026_06_25_000500_create_attachments_table.php @@ -28,6 +28,18 @@ return new class extends Migration $table->index('type'); $table->index('mime_type'); }); + + Schema::table('tenants', function (Blueprint $table) { + $table->foreign('header_logo_id') + ->references('id') + ->on('attachments') + ->nullOnDelete(); + + $table->foreign('footer_logo_id') + ->references('id') + ->on('attachments') + ->nullOnDelete(); + }); } /** @@ -35,6 +47,11 @@ return new class extends Migration */ public function down(): void { + Schema::table('tenants', function (Blueprint $table) { + $table->dropForeign(['header_logo_id']); + $table->dropForeign(['footer_logo_id']); + }); + Schema::dropIfExists('attachments'); } }; diff --git a/tests/Feature/Tenant/BootstrapTenantControllerTest.php b/tests/Feature/Tenant/BootstrapTenantControllerTest.php index 1a16989..6521c18 100644 --- a/tests/Feature/Tenant/BootstrapTenantControllerTest.php +++ b/tests/Feature/Tenant/BootstrapTenantControllerTest.php @@ -15,6 +15,24 @@ class BootstrapTenantControllerTest extends TestCase public function test_it_bootstraps_a_tenant_by_domain(): void { + $hdrKey = (string) Str::uuid(); + $ftrKey = (string) Str::uuid(); + + $headerAttachment = \App\Domains\Attachable\Models\Attachment::create([ + 'key' => $hdrKey, + 'path' => 'tenants/' . $hdrKey . '.png', + 'filename' => 'logo_header.png', + 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $footerAttachment = \App\Domains\Attachable\Models\Attachment::create([ + 'key' => $ftrKey, + 'path' => 'tenants/' . $ftrKey . '.png', + 'filename' => 'logo_footer.png', + 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $tenant = Tenant::create([ 'codigo' => 'acme', 'nombre' => 'Acme', @@ -23,8 +41,8 @@ class BootstrapTenantControllerTest extends TestCase 'secondary_color' => '#00ff00', 'danger_color' => '#0000ff', 'header_footer_bg_color' => '#ffffff', - 'header_logo' => 'logo_header.png', - 'footer_logo' => 'logo_footer.png', + 'header_logo_id' => $headerAttachment->id, + 'footer_logo_id' => $footerAttachment->id, ]); $response = $this->getJson('/api/tenants/bootstrap/acme.com'); @@ -36,9 +54,19 @@ class BootstrapTenantControllerTest extends TestCase ->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'); + ->assertJsonPath('data.header_footer_bg_color', '#ffffff'); + + $headerUrl = $response->json('data.header_logo'); + $footerUrl = $response->json('data.footer_logo'); + + $this->assertStringContainsString($headerAttachment->key, $headerUrl); + $this->assertStringContainsString($footerAttachment->key, $footerUrl); + $this->assertTrue( + str_contains($headerUrl, 'Expires=') || str_contains($headerUrl, 'expiration=') || str_contains($headerUrl, 'X-Amz-Expires=') + ); + $this->assertTrue( + str_contains($footerUrl, 'Expires=') || str_contains($footerUrl, 'expiration=') || str_contains($footerUrl, 'X-Amz-Expires=') + ); $this->assertArrayNotHasKey('props', $response->json('data')); } @@ -92,11 +120,10 @@ class BootstrapTenantControllerTest extends TestCase ->assertJsonPath('data.danger_color', '#333333') ->assertJsonPath('data.header_footer_bg_color', '#444444'); - $headerUuid = $firstResponse->json('data.header_logo'); - $footerUuid = $firstResponse->json('data.footer_logo'); + $tenant = Tenant::query()->with(['headerLogo', 'footerLogo'])->where('codigo', 'acme')->firstOrFail(); - $this->assertTrue(Str::isUuid($headerUuid)); - $this->assertTrue(Str::isUuid($footerUuid)); + $this->assertNotNull($tenant->header_logo_id); + $this->assertNotNull($tenant->footer_logo_id); $this->assertDatabaseHas('tenants', [ 'codigo' => 'acme', @@ -104,10 +131,22 @@ class BootstrapTenantControllerTest extends TestCase 'secondary_color' => '#222222', 'danger_color' => '#333333', 'header_footer_bg_color' => '#444444', - 'header_logo' => $headerUuid, - 'footer_logo' => $footerUuid, + 'header_logo_id' => $tenant->header_logo_id, + 'footer_logo_id' => $tenant->footer_logo_id, ]); + $headerUrl = $firstResponse->json('data.header_logo'); + $footerUrl = $firstResponse->json('data.footer_logo'); + + $this->assertStringContainsString($tenant->headerLogo->key, $headerUrl); + $this->assertStringContainsString($tenant->footerLogo->key, $footerUrl); + $this->assertTrue( + str_contains($headerUrl, 'Expires=') || str_contains($headerUrl, 'expiration=') || str_contains($headerUrl, 'X-Amz-Expires=') + ); + $this->assertTrue( + str_contains($footerUrl, 'Expires=') || str_contains($footerUrl, 'expiration=') || str_contains($footerUrl, 'X-Amz-Expires=') + ); + $secondResponse = $this->postJson('/api/tenants', [ 'codigo' => 'globex', 'nombre' => 'Globex', @@ -136,6 +175,21 @@ class BootstrapTenantControllerTest extends TestCase $hdrUuid = (string) Str::uuid(); $ftrUuid = (string) Str::uuid(); + $hdrAttachment = \App\Domains\Attachable\Models\Attachment::create([ + 'key' => $hdrUuid, + 'path' => 'tenants/' . $hdrUuid . '.png', + 'filename' => 'hdr.png', + 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $ftrAttachment = \App\Domains\Attachable\Models\Attachment::create([ + 'key' => $ftrUuid, + 'path' => 'tenants/' . $ftrUuid . '.png', + 'filename' => 'ftr.png', + 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $successfulResponse = $this->putJson("/api/tenants/{$tenant->id}", [ 'codigo' => 'acme', 'nombre' => 'Acme Updated', @@ -155,9 +209,19 @@ class BootstrapTenantControllerTest extends TestCase ->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); + ->assertJsonPath('data.header_footer_bg_color', '#888888'); + + $headerUrl = $successfulResponse->json('data.header_logo'); + $footerUrl = $successfulResponse->json('data.footer_logo'); + + $this->assertStringContainsString($hdrUuid, $headerUrl); + $this->assertStringContainsString($ftrUuid, $footerUrl); + $this->assertTrue( + str_contains($headerUrl, 'Expires=') || str_contains($headerUrl, 'expiration=') || str_contains($headerUrl, 'X-Amz-Expires=') + ); + $this->assertTrue( + str_contains($footerUrl, 'Expires=') || str_contains($footerUrl, 'expiration=') || str_contains($footerUrl, 'X-Amz-Expires=') + ); $this->assertDatabaseHas('tenants', [ 'id' => $tenant->id, @@ -165,8 +229,8 @@ class BootstrapTenantControllerTest extends TestCase 'secondary_color' => '#666666', 'danger_color' => '#777777', 'header_footer_bg_color' => '#888888', - 'header_logo' => $hdrUuid, - 'footer_logo' => $ftrUuid, + 'header_logo_id' => $hdrAttachment->id, + 'footer_logo_id' => $ftrAttachment->id, ]); $failingResponse = $this->putJson("/api/tenants/{$otherTenant->id}", [ @@ -236,14 +300,25 @@ class BootstrapTenantControllerTest extends TestCase $response->assertCreated(); - $headerUuid = $response->json('data.header_logo'); - $footerUuid = $response->json('data.footer_logo'); + $tenant = Tenant::query()->with(['headerLogo', 'footerLogo'])->where('codigo', 'acme')->firstOrFail(); - $this->assertTrue(Str::isUuid($headerUuid)); - $this->assertTrue(Str::isUuid($footerUuid)); + $this->assertNotNull($tenant->header_logo_id); + $this->assertNotNull($tenant->footer_logo_id); - $this->assertDatabaseHas('attachments', ['key' => $headerUuid]); - $this->assertDatabaseHas('attachments', ['key' => $footerUuid]); + $headerUrl = $response->json('data.header_logo'); + $footerUrl = $response->json('data.footer_logo'); + + $this->assertStringContainsString($tenant->headerLogo->key, $headerUrl); + $this->assertStringContainsString($tenant->footerLogo->key, $footerUrl); + $this->assertTrue( + str_contains($headerUrl, 'Expires=') || str_contains($headerUrl, 'expiration=') || str_contains($headerUrl, 'X-Amz-Expires=') + ); + $this->assertTrue( + str_contains($footerUrl, 'Expires=') || str_contains($footerUrl, 'expiration=') || str_contains($footerUrl, 'X-Amz-Expires=') + ); + + $this->assertDatabaseHas('attachments', ['key' => $tenant->headerLogo->key]); + $this->assertDatabaseHas('attachments', ['key' => $tenant->footerLogo->key]); } public function test_it_stores_base64_logos_in_tenants_directory(): void @@ -260,8 +335,17 @@ class BootstrapTenantControllerTest extends TestCase $response->assertCreated(); - $headerUuid = $response->json('data.header_logo'); - $this->assertTrue(Str::isUuid($headerUuid)); - $this->assertDatabaseHas('attachments', ['key' => $headerUuid]); + $tenant = Tenant::query()->with('headerLogo')->where('codigo', 'acme')->firstOrFail(); + + $this->assertNotNull($tenant->header_logo_id); + + $headerUrl = $response->json('data.header_logo'); + + $this->assertStringContainsString($tenant->headerLogo->key, $headerUrl); + $this->assertTrue( + str_contains($headerUrl, 'Expires=') || str_contains($headerUrl, 'expiration=') || str_contains($headerUrl, 'X-Amz-Expires=') + ); + + $this->assertDatabaseHas('attachments', ['key' => $tenant->headerLogo->key]); } }