From ff61a3d3b832f8eb8887f471655080c37bb3cac7 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 26 Aug 2026 14:37:17 -0300 Subject: [PATCH 1/2] feat(tenant): share website type favicon attachment --- .../Tenant/Services/WebsiteTypeService.php | 13 +++ ..._08_26_000000_set_website_type_favicon.php | 96 +++++++++++++++++++ database/seeders/WebsiteTypeSeeder.php | 19 ++++ .../images/website_types/onticket_favicon.svg | 3 + .../Migrations/SetWebsiteTypeFaviconTest.php | 61 ++++++++++++ .../Feature/Seeders/WebsiteTypeSeederTest.php | 6 +- .../Feature/Tenant/WebsiteTypeServiceTest.php | 27 ++++++ 7 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_08_26_000000_set_website_type_favicon.php create mode 100644 public/images/website_types/onticket_favicon.svg create mode 100644 tests/Feature/Migrations/SetWebsiteTypeFaviconTest.php diff --git a/app/Domains/Tenant/Services/WebsiteTypeService.php b/app/Domains/Tenant/Services/WebsiteTypeService.php index f89d69f..9c57eba 100644 --- a/app/Domains/Tenant/Services/WebsiteTypeService.php +++ b/app/Domains/Tenant/Services/WebsiteTypeService.php @@ -82,6 +82,7 @@ class WebsiteTypeService && $previousLogo->id !== $websiteType->site_logo && $previousLogo->id !== $websiteType->footer_logo && $previousLogo->id !== $websiteType->favicon_id + && ! $this->isReferencedByWebsiteType($previousLogo) ) { $this->attachmentService->delete($previousLogo); } @@ -90,4 +91,16 @@ class WebsiteTypeService return $websiteType; }); } + + private function isReferencedByWebsiteType(Attachment $attachment): bool + { + return WebsiteType::query() + ->where(function ($query) use ($attachment): void { + $query + ->where('site_logo', $attachment->id) + ->orWhere('footer_logo', $attachment->id) + ->orWhere('favicon_id', $attachment->id); + }) + ->exists(); + } } diff --git a/database/migrations/2026_08_26_000000_set_website_type_favicon.php b/database/migrations/2026_08_26_000000_set_website_type_favicon.php new file mode 100644 index 0000000..e3699ed --- /dev/null +++ b/database/migrations/2026_08_26_000000_set_website_type_favicon.php @@ -0,0 +1,96 @@ + */ + private const WEBSITE_TYPE_CODES = ['shopit', 'onticket']; + + public function up(): void + { + $websiteTypes = DB::table('website_type') + ->whereIn('codigo', self::WEBSITE_TYPE_CODES) + ->get(['codigo', 'favicon_id']); + + if ($websiteTypes->isEmpty()) { + return; + } + + $faviconIds = $websiteTypes + ->pluck('favicon_id') + ->filter() + ->unique() + ->values(); + + if ( + $faviconIds->count() === 1 + && DB::table('attachments') + ->where('id', $faviconIds->first()) + ->where('filename', self::FILENAME) + ->exists() + && $websiteTypes->every( + fn (object $websiteType): bool => $websiteType->favicon_id === $faviconIds->first() + ) + ) { + return; + } + + $sourcePath = public_path('images/website_types/'.self::FILENAME); + + if (! is_file($sourcePath)) { + throw new RuntimeException("Favicon not found at path: {$sourcePath}"); + } + + $contents = file_get_contents($sourcePath); + + if ($contents === false) { + throw new RuntimeException("Could not read favicon at path: {$sourcePath}"); + } + + $key = (string) Str::uuid(); + $storedPath = "website-types/{$key}.svg"; + + if (! Storage::disk('s3')->put($storedPath, $contents)) { + throw new RuntimeException("Could not store favicon at path: {$storedPath}"); + } + + try { + DB::transaction(function () use ($contents, $key, $storedPath): void { + $attachmentId = DB::table('attachments')->insertGetId([ + 'key' => $key, + 'path' => $storedPath, + 'filename' => self::FILENAME, + 'type' => 'image', + 'mime_type' => 'image/svg+xml', + 'extension' => 'svg', + 'size' => strlen($contents), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('website_type') + ->whereIn('codigo', self::WEBSITE_TYPE_CODES) + ->update([ + 'favicon_id' => $attachmentId, + 'updated_at' => now(), + ]); + }); + } catch (Throwable $throwable) { + Storage::disk('s3')->delete($storedPath); + + throw $throwable; + } + } + + public function down(): void + { + // The shared attachment may be in use outside these website types. + // Keep this data migration irreversible to avoid deleting an active asset. + } +}; diff --git a/database/seeders/WebsiteTypeSeeder.php b/database/seeders/WebsiteTypeSeeder.php index 3b022a4..7db0556 100644 --- a/database/seeders/WebsiteTypeSeeder.php +++ b/database/seeders/WebsiteTypeSeeder.php @@ -36,6 +36,7 @@ class WebsiteTypeSeeder extends Seeder ...self::PRESENTATION, 'site_logo' => $this->onTicketLogo(), 'footer_logo' => $this->onTicketFooterLogo(), + 'favicon' => $this->onTicketFavicon(), ], ); @@ -70,6 +71,7 @@ class WebsiteTypeSeeder extends Seeder ...self::PRESENTATION, 'site_logo' => $this->onTicketLogo(), 'footer_logo' => $this->onTicketFooterLogo(), + 'favicon' => $shopIt->favicon()->firstOrFail()->key, ], ); @@ -175,4 +177,21 @@ class WebsiteTypeSeeder extends Seeder true, ); } + + private function onTicketFavicon(): UploadedFile + { + $path = public_path('images/website_types/onticket_favicon.svg'); + + if (! file_exists($path)) { + throw new RuntimeException("OnTicket favicon not found at path: {$path}"); + } + + return new UploadedFile( + $path, + 'onticket_favicon.svg', + 'image/svg+xml', + null, + true, + ); + } } diff --git a/public/images/website_types/onticket_favicon.svg b/public/images/website_types/onticket_favicon.svg new file mode 100644 index 0000000..30502c6 --- /dev/null +++ b/public/images/website_types/onticket_favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/Feature/Migrations/SetWebsiteTypeFaviconTest.php b/tests/Feature/Migrations/SetWebsiteTypeFaviconTest.php new file mode 100644 index 0000000..674130f --- /dev/null +++ b/tests/Feature/Migrations/SetWebsiteTypeFaviconTest.php @@ -0,0 +1,61 @@ +insert([ + [ + 'codigo' => 'shopit', + 'nombre' => 'ShopIt', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'codigo' => 'onticket', + 'nombre' => 'OnTicket', + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $migration = require database_path( + 'migrations/2026_08_26_000000_set_website_type_favicon.php' + ); + + $migration->up(); + $migration->up(); + + $faviconIds = DB::table('website_type') + ->whereIn('codigo', ['shopit', 'onticket']) + ->pluck('favicon_id'); + + $this->assertCount(2, $faviconIds); + $this->assertNotNull($faviconIds->first()); + $this->assertSame(1, $faviconIds->unique()->count()); + + $attachment = DB::table('attachments')->where('id', $faviconIds->first())->first(); + + $this->assertNotNull($attachment); + $this->assertSame('onticket_favicon.svg', $attachment->filename); + $this->assertSame('image/svg+xml', $attachment->mime_type); + $this->assertSame('svg', $attachment->extension); + $this->assertSame(1, DB::table('attachments')->where('filename', 'onticket_favicon.svg')->count()); + Storage::disk('s3')->assertExists($attachment->path); + $this->assertSame( + file_get_contents(public_path('images/website_types/onticket_favicon.svg')), + Storage::disk('s3')->get($attachment->path), + ); + } +} diff --git a/tests/Feature/Seeders/WebsiteTypeSeederTest.php b/tests/Feature/Seeders/WebsiteTypeSeederTest.php index c91c003..d286cf6 100644 --- a/tests/Feature/Seeders/WebsiteTypeSeederTest.php +++ b/tests/Feature/Seeders/WebsiteTypeSeederTest.php @@ -21,7 +21,7 @@ class WebsiteTypeSeederTest extends TestCase $this->seed(WebsiteTypeSeeder::class); $this->assertSame(2, WebsiteType::query()->count()); - $this->assertSame(4, Attachment::query()->count()); + $this->assertSame(5, Attachment::query()->count()); $expectedPresentation = [ 'primary_color' => '#FF7006', @@ -49,6 +49,8 @@ class WebsiteTypeSeederTest extends TestCase Storage::disk('s3')->assertExists($shopIt->siteLogo->path); $this->assertSame('onticket_footer_logo.png', $shopIt->footerLogo->filename); Storage::disk('s3')->assertExists($shopIt->footerLogo->path); + $this->assertSame('onticket_favicon.svg', $shopIt->favicon->filename); + Storage::disk('s3')->assertExists($shopIt->favicon->path); $this->assertSame(['carousel'], $shopIt->extras->pluck('codigo')->all()); $this->assertSame('Carrusel principal', $shopIt->extras->sole()->nombre); $this->assertSame([ @@ -79,6 +81,8 @@ class WebsiteTypeSeederTest extends TestCase Storage::disk('s3')->assertExists($onTicket->footerLogo->path); $this->assertNotSame($shopIt->site_logo, $onTicket->site_logo); $this->assertNotSame($shopIt->footer_logo, $onTicket->footer_logo); + $this->assertSame($shopIt->favicon_id, $onTicket->favicon_id); + $this->assertSame('onticket_favicon.svg', $onTicket->favicon->filename); $this->assertEqualsCanonicalizing( ['heroConfig', 'eventConfig', 'additionalInfoConfig'], $onTicket->extras->pluck('codigo')->all(), diff --git a/tests/Feature/Tenant/WebsiteTypeServiceTest.php b/tests/Feature/Tenant/WebsiteTypeServiceTest.php index 176f6e8..39cd9a2 100644 --- a/tests/Feature/Tenant/WebsiteTypeServiceTest.php +++ b/tests/Feature/Tenant/WebsiteTypeServiceTest.php @@ -69,4 +69,31 @@ class WebsiteTypeServiceTest extends TestCase 'favicon_id' => $favicon->id, ]); } + + public function test_it_keeps_a_shared_favicon_when_one_website_type_replaces_it(): void + { + Storage::fake('s3'); + + $service = app(WebsiteTypeService::class); + $shopIt = $service->create([ + 'codigo' => 'shopit', + 'nombre' => 'ShopIt', + 'favicon' => UploadedFile::fake()->image('shared-favicon.png', 32, 32), + ]); + $sharedFavicon = $shopIt->favicon()->firstOrFail(); + $onTicket = $service->create([ + 'codigo' => 'onticket', + 'nombre' => 'OnTicket', + 'favicon' => $sharedFavicon->key, + ]); + + $service->updateOrCreate( + ['codigo' => 'shopit'], + ['favicon' => UploadedFile::fake()->image('shopit-favicon.png', 32, 32)], + ); + + $this->assertSame($sharedFavicon->id, $onTicket->fresh()->favicon_id); + $this->assertDatabaseHas('attachments', ['id' => $sharedFavicon->id]); + Storage::disk('s3')->assertExists($sharedFavicon->path); + } } From bbe3cf82f50736fe7341623076d78678fe08489a Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 26 Aug 2026 14:37:25 -0300 Subject: [PATCH 2/2] feat(bootstrap): expose website type favicon --- app/Domains/Bootstrap/Resources/AdminAppBootstrapResource.php | 1 + app/Domains/Bootstrap/Services/AdminAppBootstrapService.php | 2 +- app/Domains/Bootstrap/Services/ScannerBootstrapService.php | 2 +- tests/Feature/Tenant/BootstrapAdminAppControllerTest.php | 3 +++ tests/Feature/Tenant/BootstrapScannerControllerTest.php | 3 +++ tests/Unit/Bootstrap/AdminAppBootstrapResourceTest.php | 2 ++ 6 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/Domains/Bootstrap/Resources/AdminAppBootstrapResource.php b/app/Domains/Bootstrap/Resources/AdminAppBootstrapResource.php index 296e284..63ba328 100644 --- a/app/Domains/Bootstrap/Resources/AdminAppBootstrapResource.php +++ b/app/Domains/Bootstrap/Resources/AdminAppBootstrapResource.php @@ -30,6 +30,7 @@ class AdminAppBootstrapResource extends JsonResource 'login_header_footer_color' => $websiteType->login_header_footer_color, 'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440), 'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440), + 'favicon' => $websiteType->favicon?->getTemporaryUrl(1440), ]; } } diff --git a/app/Domains/Bootstrap/Services/AdminAppBootstrapService.php b/app/Domains/Bootstrap/Services/AdminAppBootstrapService.php index 572579a..743f4a0 100644 --- a/app/Domains/Bootstrap/Services/AdminAppBootstrapService.php +++ b/app/Domains/Bootstrap/Services/AdminAppBootstrapService.php @@ -11,7 +11,7 @@ class AdminAppBootstrapService { return [ 'website_type' => WebsiteType::query() - ->with(['siteLogo', 'footerLogo']) + ->with(['siteLogo', 'footerLogo', 'favicon']) ->where('dominio', $domain) ->firstOrFail(), ]; diff --git a/app/Domains/Bootstrap/Services/ScannerBootstrapService.php b/app/Domains/Bootstrap/Services/ScannerBootstrapService.php index 392c477..4a088a1 100644 --- a/app/Domains/Bootstrap/Services/ScannerBootstrapService.php +++ b/app/Domains/Bootstrap/Services/ScannerBootstrapService.php @@ -11,7 +11,7 @@ class ScannerBootstrapService { return [ 'website_type' => WebsiteType::query() - ->with(['siteLogo', 'footerLogo']) + ->with(['siteLogo', 'footerLogo', 'favicon']) ->where('scanner_domain', $domain) ->firstOrFail(), ]; diff --git a/tests/Feature/Tenant/BootstrapAdminAppControllerTest.php b/tests/Feature/Tenant/BootstrapAdminAppControllerTest.php index c64940b..9912805 100644 --- a/tests/Feature/Tenant/BootstrapAdminAppControllerTest.php +++ b/tests/Feature/Tenant/BootstrapAdminAppControllerTest.php @@ -14,6 +14,7 @@ class BootstrapAdminAppControllerTest extends TestCase public function test_it_publicly_bootstraps_the_admin_app_by_domain(): void { $footerLogo = Attachment::factory()->create(); + $favicon = Attachment::factory()->create(); WebsiteType::query()->create([ 'codigo' => 'shopit', @@ -31,6 +32,7 @@ class BootstrapAdminAppControllerTest extends TestCase 'border_color' => '#eaeaea', 'login_header_footer_color' => '#313131', 'footer_logo' => $footerLogo->id, + 'favicon_id' => $favicon->id, ]); $this->getJson('/api/v1/adminapp/bootstrap/ADMIN.SHOPIT.TEST') @@ -41,6 +43,7 @@ class BootstrapAdminAppControllerTest extends TestCase ->assertJsonPath('data.login_header_footer_color', '#313131') ->assertJsonPath('data.site_logo', null) ->assertJsonPath('data.footer_logo', $footerLogo->getTemporaryUrl(1440)) + ->assertJsonPath('data.favicon', $favicon->getTemporaryUrl(1440)) ->assertJsonMissingPath('data.forms') ->assertJsonMissingPath('data.codigo') ->assertJsonMissingPath('data.nombre') diff --git a/tests/Feature/Tenant/BootstrapScannerControllerTest.php b/tests/Feature/Tenant/BootstrapScannerControllerTest.php index e4eb18f..c40c9e1 100644 --- a/tests/Feature/Tenant/BootstrapScannerControllerTest.php +++ b/tests/Feature/Tenant/BootstrapScannerControllerTest.php @@ -14,6 +14,7 @@ class BootstrapScannerControllerTest extends TestCase public function test_it_publicly_bootstraps_the_scanner_by_scanner_domain(): void { $siteLogo = Attachment::factory()->create(); + $favicon = Attachment::factory()->create(); WebsiteType::query()->create([ 'codigo' => 'shopit', @@ -32,6 +33,7 @@ class BootstrapScannerControllerTest extends TestCase 'border_color' => '#eaeaea', 'login_header_footer_color' => '#313131', 'site_logo' => $siteLogo->id, + 'favicon_id' => $favicon->id, ]); $this->getJson('/api/v1/scanner/bootstrap/SCANNER.SHOPIT.TEST') @@ -40,6 +42,7 @@ class BootstrapScannerControllerTest extends TestCase ->assertJsonPath('data.primary_color', '#112233') ->assertJsonPath('data.site_logo', $siteLogo->getTemporaryUrl(1440)) ->assertJsonPath('data.footer_logo', null) + ->assertJsonPath('data.favicon', $favicon->getTemporaryUrl(1440)) ->assertJsonMissingPath('data.codigo') ->assertJsonMissingPath('data.nombre') ->assertJsonMissingPath('data.dominio') diff --git a/tests/Unit/Bootstrap/AdminAppBootstrapResourceTest.php b/tests/Unit/Bootstrap/AdminAppBootstrapResourceTest.php index eb142f6..7a01e39 100644 --- a/tests/Unit/Bootstrap/AdminAppBootstrapResourceTest.php +++ b/tests/Unit/Bootstrap/AdminAppBootstrapResourceTest.php @@ -17,11 +17,13 @@ class AdminAppBootstrapResourceTest extends TestCase ]); $websiteType->setRelation('siteLogo', null); $websiteType->setRelation('footerLogo', null); + $websiteType->setRelation('favicon', null); $data = AdminAppBootstrapResource::make([ 'website_type' => $websiteType, ])->resolve(request()); $this->assertSame('shopit', $data['website_type_code']); + $this->assertNull($data['favicon']); $this->assertArrayNotHasKey('forms', $data); } }