feat(tenant): add site title and favicon support for tenants and website types

This commit is contained in:
2026-08-14 08:55:59 -03:00
parent 4de08ff2f2
commit bfdaf1c38b
17 changed files with 355 additions and 6 deletions

View File

@@ -21,6 +21,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'codigo',
'nombre',
'dominio',
'site_title',
'primary_color',
'secondary_color',
'danger_color',
@@ -29,6 +30,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'footer_bg_color',
'header_logo_id',
'footer_logo_id',
'favicon_id',
'header_bg_image_id',
'footer_bg_image_id',
'website_type_code',
@@ -101,6 +103,14 @@ class Tenant extends Model
return $this->belongsTo(Attachment::class, 'footer_logo_id');
}
/**
* @return BelongsTo<Attachment, $this>
*/
public function favicon(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'favicon_id');
}
/**
* @return BelongsTo<Attachment, $this>
*/

View File

@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'nombre',
'dominio',
'scanner_domain',
'site_title',
'primary_color',
'secondary_color',
'danger_color',
@@ -27,6 +28,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'login_header_footer_color',
'site_logo',
'footer_logo',
'favicon_id',
])]
class WebsiteType extends Model
{
@@ -50,6 +52,14 @@ class WebsiteType extends Model
return $this->belongsTo(Attachment::class, 'footer_logo');
}
/**
* @return BelongsTo<Attachment, $this>
*/
public function favicon(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'favicon_id');
}
/**
* @return HasMany<WebsiteTypeExtra, $this>
*/

View File

@@ -55,6 +55,7 @@ class StoreTenantRequest extends FormRequest
'max:255',
Rule::unique('tenants', 'dominio'),
],
'site_title' => ['sometimes', 'nullable', 'string', 'max:255'],
'primary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'secondary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'danger_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
@@ -63,6 +64,7 @@ class StoreTenantRequest extends FormRequest
'footer_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_logo' => $logoRule,
'footer_logo' => $logoRule,
'favicon' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'header_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'footer_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'social_media' => ['sometimes', 'array'],

View File

@@ -65,6 +65,7 @@ class UpdateTenantRequest extends FormRequest
'max:255',
Rule::unique('tenants', 'dominio')->ignore($tenant?->id),
],
'site_title' => ['sometimes', 'nullable', 'string', 'max:255'],
'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})$/'],
@@ -73,6 +74,7 @@ class UpdateTenantRequest extends FormRequest
'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,
'favicon' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'header_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'footer_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
'social_media' => ['sometimes', 'array'],

View File

@@ -26,6 +26,11 @@ class TenantResource extends JsonResource
'codigo' => $this->codigo,
'nombre' => $this->nombre,
'dominio' => $this->dominio,
'site_title' => $this->site_title
?? $this->websiteType?->site_title
?? 'ShopitFront',
'favicon' => ($this->favicon ?? $this->websiteType?->favicon)
?->getTemporaryUrl(1440),
'primary_color' => $this->primary_color,
'secondary_color' => $this->secondary_color,
'danger_color' => $this->danger_color,

View File

@@ -13,6 +13,8 @@ class TenantInformationService
private const DEFAULT_RELATIONS = [
'headerLogo',
'footerLogo',
'favicon',
'websiteType.favicon',
'headerBackgroundImage',
'footerBackgroundImage',
'socialMedia',

View File

@@ -25,6 +25,7 @@ class TenantService
return DB::transaction(function () use ($data): Tenant {
$headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null;
$favicon = $data['favicon'] ?? null;
$headerBackgroundImage = $data['header_bg_image'] ?? null;
$footerBackgroundImage = $data['footer_bg_image'] ?? null;
$socialMedia = $data['social_media'] ?? [];
@@ -33,6 +34,7 @@ class TenantService
unset(
$data['header_logo'],
$data['footer_logo'],
$data['favicon'],
$data['header_bg_image'],
$data['footer_bg_image'],
$data['social_media'],
@@ -63,6 +65,7 @@ class TenantService
$data['header_logo_id'] = $headerAttachmentId;
$data['footer_logo_id'] = $footerAttachmentId;
$data['favicon_id'] = $this->storeTenantImage($favicon);
$data['header_bg_image_id'] = $this->storeTenantImage($headerBackgroundImage);
$data['footer_bg_image_id'] = $this->storeTenantImage($footerBackgroundImage);
@@ -85,11 +88,13 @@ class TenantService
return DB::transaction(function () use ($tenant, $data): Tenant {
$hasHeaderLogoKey = array_key_exists('header_logo', $data);
$hasFooterLogoKey = array_key_exists('footer_logo', $data);
$hasFaviconKey = array_key_exists('favicon', $data);
$hasHeaderBackgroundImageKey = array_key_exists('header_bg_image', $data);
$hasFooterBackgroundImageKey = array_key_exists('footer_bg_image', $data);
$hasSocialMediaKey = array_key_exists('social_media', $data);
$headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null;
$favicon = $data['favicon'] ?? null;
$headerBackgroundImage = $data['header_bg_image'] ?? null;
$footerBackgroundImage = $data['footer_bg_image'] ?? null;
$socialMedia = $data['social_media'] ?? [];
@@ -97,6 +102,7 @@ class TenantService
unset(
$data['header_logo'],
$data['footer_logo'],
$data['favicon'],
$data['header_bg_image'],
$data['footer_bg_image'],
$data['social_media']
@@ -136,6 +142,10 @@ class TenantService
}
}
if ($hasFaviconKey) {
$tenant->favicon_id = $this->storeTenantImage($favicon);
}
if ($hasHeaderBackgroundImageKey) {
$tenant->header_bg_image_id = $this->storeTenantImage($headerBackgroundImage);
}

View File

@@ -46,14 +46,20 @@ class WebsiteTypeService
return DB::transaction(function () use ($websiteType, $data): WebsiteType {
$previousLogos = [];
foreach (['site_logo' => 'siteLogo', 'footer_logo' => 'footerLogo'] as $field => $relation) {
$attachmentFields = [
'site_logo' => ['relation' => 'siteLogo', 'column' => 'site_logo'],
'footer_logo' => ['relation' => 'footerLogo', 'column' => 'footer_logo'],
'favicon' => ['relation' => 'favicon', 'column' => 'favicon_id'],
];
foreach ($attachmentFields as $field => $attachmentField) {
if (! array_key_exists($field, $data)) {
continue;
}
$logo = $data[$field];
$previousLogos[$field] = $websiteType->exists
? $websiteType->{$relation}()->first()
? $websiteType->{$attachmentField['relation']}()->first()
: null;
unset($data[$field]);
@@ -65,17 +71,17 @@ class WebsiteTypeService
: $this->attachmentService->store($logo, 'website-types');
}
$data[$field] = $attachment?->id;
$data[$attachmentField['column']] = $attachment?->id;
}
$websiteType->fill($data)->save();
foreach ($previousLogos as $field => $previousLogo) {
foreach ($previousLogos as $previousLogo) {
if (
$previousLogo instanceof Attachment
&& $previousLogo->id !== $websiteType->{$field}
&& $previousLogo->id !== $websiteType->site_logo
&& $previousLogo->id !== $websiteType->footer_logo
&& $previousLogo->id !== $websiteType->favicon_id
) {
$this->attachmentService->delete($previousLogo);
}

View File

@@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('website_type', function (Blueprint $table): void {
$table->string('site_title')->nullable()->after('scanner_domain');
$table->unsignedBigInteger('favicon_id')->nullable()->after('footer_logo');
$table->foreign('favicon_id')
->references('id')
->on('attachments')
->nullOnDelete();
});
Schema::table('tenants', function (Blueprint $table): void {
$table->string('site_title')->nullable()->after('dominio');
$table->unsignedBigInteger('favicon_id')->nullable()->after('footer_logo_id');
$table->foreign('favicon_id')
->references('id')
->on('attachments')
->nullOnDelete();
});
DB::table('website_type')
->whereNull('site_title')
->update(['site_title' => DB::raw('nombre')]);
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table): void {
if (Schema::getConnection()->getDriverName() !== 'sqlite') {
$table->dropForeign(['favicon_id']);
}
$table->dropColumn(['site_title', 'favicon_id']);
});
Schema::table('website_type', function (Blueprint $table): void {
if (Schema::getConnection()->getDriverName() !== 'sqlite') {
$table->dropForeign(['favicon_id']);
}
$table->dropColumn(['site_title', 'favicon_id']);
});
}
};

View File

@@ -0,0 +1,134 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
return new class extends Migration
{
/** @var array<string, array{title: string, image: string, filename: string}> */
private const BRANDING = [
'fiesta_futbol_infantil' => [
'title' => 'Fiesta Fútbol Infantil',
'image' => 'images/tennants/fiesta_futbol_infantil/futbol_infantil_favicon.png',
'filename' => 'futbol_infantil_favicon.png',
],
'desfile_pura_tendencia' => [
'title' => 'Desfile Pura Tendencia',
'image' => 'images/tennants/desfile_pura_tendencia/pura_tendencia_favicon.png',
'filename' => 'pura_tendencia_favicon.png',
],
];
/** @var list<string> */
private array $storedPaths = [];
public function up(): void
{
try {
DB::transaction(function (): void {
foreach (self::BRANDING as $tenantCode => $branding) {
$tenant = DB::table('tenants')
->where('codigo', $tenantCode)
->first(['id', 'favicon_id']);
if ($tenant === null) {
continue;
}
$faviconId = $tenant->favicon_id;
$currentFilename = $faviconId === null
? null
: DB::table('attachments')->where('id', $faviconId)->value('filename');
if ($currentFilename !== $branding['filename']) {
$faviconId = $this->storeFavicon(
$tenantCode,
$branding['image'],
$branding['filename'],
);
}
DB::table('tenants')
->where('id', $tenant->id)
->update([
'site_title' => $branding['title'],
'favicon_id' => $faviconId,
'updated_at' => now(),
]);
}
});
} catch (Throwable $throwable) {
Storage::disk('s3')->delete($this->storedPaths);
throw $throwable;
}
}
public function down(): void
{
foreach (self::BRANDING as $tenantCode => $branding) {
$tenant = DB::table('tenants')
->where('codigo', $tenantCode)
->first(['id', 'site_title', 'favicon_id']);
if ($tenant === null) {
continue;
}
$favicon = $tenant->favicon_id === null
? null
: DB::table('attachments')->where('id', $tenant->favicon_id)->first();
DB::table('tenants')
->where('id', $tenant->id)
->update([
'site_title' => $tenant->site_title === $branding['title'] ? null : $tenant->site_title,
'favicon_id' => $favicon?->filename === $branding['filename'] ? null : $tenant->favicon_id,
'updated_at' => now(),
]);
if ($favicon?->filename === $branding['filename']) {
Storage::disk('s3')->delete($favicon->path);
DB::table('attachments')->where('id', $favicon->id)->delete();
}
}
}
private function storeFavicon(string $tenantCode, string $relativePath, string $filename): int
{
$sourcePath = public_path($relativePath);
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 = "tenants/{$tenantCode}/{$key}.png";
if (! Storage::disk('s3')->put($storedPath, $contents)) {
throw new RuntimeException("Could not store favicon at path: {$storedPath}");
}
$this->storedPaths[] = $storedPath;
return DB::table('attachments')->insertGetId([
'key' => $key,
'path' => $storedPath,
'filename' => $filename,
'type' => 'image',
'mime_type' => 'image/png',
'extension' => 'png',
'size' => strlen($contents),
'created_at' => now(),
'updated_at' => now(),
]);
}
};

View File

@@ -104,6 +104,7 @@ class TenantSeeder extends Seeder
'codigo' => 'fiesta_futbol_infantil',
'nombre' => 'Fiesta Fútbol Infantil',
'dominio' => $fiestaDomain,
'site_title' => 'Fiesta Fútbol Infantil',
'primary_color' => '#00973F',
'secondary_color' => '#A0A0A0',
'danger_color' => '#FF8888',
@@ -121,6 +122,10 @@ class TenantSeeder extends Seeder
'images/tennants/fiesta_futbol_infantil/futbol_infantil_footer.png',
'futbol_infantil_footer.png',
),
'favicon' => $this->uploadedImage(
'images/tennants/fiesta_futbol_infantil/futbol_infantil_favicon.png',
'futbol_infantil_favicon.png',
),
'social_media' => self::SOCIAL_MEDIA,
'website_type_code' => 'onticket',
'extras' => [
@@ -229,6 +234,18 @@ class TenantSeeder extends Seeder
}
}
if (
$tenant->favicon
&& $tenant->favicon_id !== $tenant->header_logo_id
&& $tenant->favicon_id !== $tenant->footer_logo_id
) {
try {
$attachmentService->delete($tenant->favicon);
} catch (Throwable) {
// Ignore cleanup errors while recreating demo data.
}
}
$tenant->delete();
}

View File

@@ -32,6 +32,7 @@ class WebsiteTypeSeeder extends Seeder
'nombre' => 'ShopIt',
'dominio' => 'localhost',
'scanner_domain' => 'scanner.localhost',
'site_title' => 'ShopIt',
...self::PRESENTATION,
'site_logo' => $this->onTicketLogo(),
'footer_logo' => $this->onTicketFooterLogo(),
@@ -65,6 +66,7 @@ class WebsiteTypeSeeder extends Seeder
'nombre' => 'OnTicket',
'dominio' => 'onticket.localhost',
'scanner_domain' => 'scanner.onticket.localhost',
'site_title' => 'OnTicket',
...self::PRESENTATION,
'site_logo' => $this->onTicketLogo(),
'footer_logo' => $this->onTicketFooterLogo(),

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -36,6 +36,11 @@ class CreateDesfilePuraTendenciaTenantTest extends TestCase
);
$footerBackgroundMigration->up();
$browserBrandingMigration = require database_path(
'migrations/2026_08_14_010000_set_seeded_tenant_browser_branding.php'
);
$browserBrandingMigration->up();
$this->assertDatabaseHas('tenants', [
'codigo' => 'desfile_pura_tendencia',
'nombre' => 'Desfile Pura Tendencia',
@@ -52,8 +57,25 @@ class CreateDesfilePuraTendenciaTenantTest extends TestCase
'footer_bg_color' => '#D4441C',
'display_categories' => false,
'display_seach_bar' => false,
'site_title' => 'Desfile Pura Tendencia',
]);
$this->assertDatabaseHas('tenants', [
'codigo' => 'fiesta_futbol_infantil',
'site_title' => 'Fiesta Fútbol Infantil',
]);
foreach ([
'fiesta_futbol_infantil' => 'futbol_infantil_favicon.png',
'desfile_pura_tendencia' => 'pura_tendencia_favicon.png',
] as $tenantCode => $faviconFilename) {
$faviconId = DB::table('tenants')->where('codigo', $tenantCode)->value('favicon_id');
$favicon = DB::table('attachments')->where('id', $faviconId)->sole();
$this->assertSame($faviconFilename, $favicon->filename);
Storage::disk('s3')->assertExists($favicon->path);
}
$eventDate = DB::table('event_dates')
->where('tenant_code', 'desfile_pura_tendencia')
->sole();
@@ -104,6 +126,7 @@ class CreateDesfilePuraTendenciaTenantTest extends TestCase
'desfile_pura_tendencia_footer.png',
'desfile_pura_tendencia_hero.png',
'desfile_pura_tendencia_footer_background.png',
'pura_tendencia_favicon.png',
'entrada_pasarela.png',
] as $filename) {
$path = DB::table('attachments')->where('filename', $filename)->value('path');

View File

@@ -127,6 +127,62 @@ class BootstrapTenantControllerTest extends TestCase
->assertJsonPath('data.dominio', 'acme.com');
}
public function test_it_resolves_browser_branding_from_the_tenant_before_the_website_type(): void
{
$typeFavicon = Attachment::query()->create([
'key' => (string) Str::uuid(),
'path' => 'website-types/type-favicon.png',
'filename' => 'type-favicon.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$tenantFavicon = Attachment::query()->create([
'key' => (string) Str::uuid(),
'path' => 'tenants/tenant-favicon.png',
'filename' => 'tenant-favicon.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$websiteType = WebsiteType::query()->create([
'codigo' => 'store',
'nombre' => 'Tienda',
'site_title' => 'Website type title',
'favicon_id' => $typeFavicon->id,
]);
$tenant = $this->createTenant([
'website_type_code' => $websiteType->codigo,
]);
$typeResponse = $this->getJson('/api/tenants/bootstrap/acme.com');
$typeResponse
->assertOk()
->assertJsonPath('data.site_title', 'Website type title');
$this->assertStringContainsString($typeFavicon->key, $typeResponse->json('data.favicon'));
$tenant->update([
'site_title' => 'Tenant title',
'favicon_id' => $tenantFavicon->id,
]);
$tenantResponse = $this->getJson('/api/tenants/bootstrap/acme.com');
$tenantResponse
->assertOk()
->assertJsonPath('data.site_title', 'Tenant title');
$this->assertStringContainsString($tenantFavicon->key, $tenantResponse->json('data.favicon'));
}
public function test_it_uses_default_browser_branding_when_none_is_configured(): void
{
$this->createTenant();
$this->getJson('/api/tenants/bootstrap/acme.com')
->assertOk()
->assertJsonPath('data.site_title', 'ShopitFront')
->assertJsonPath('data.favicon', null);
}
public function test_it_returns_only_the_tenant_categories_in_the_bootstrap(): void
{
$tenant = $this->createTenant();
@@ -755,21 +811,28 @@ class BootstrapTenantControllerTest extends TestCase
'footer_bg_color' => '#444444',
'header_logo' => $base64Image,
'footer_logo' => $ftrUuid,
'site_title' => 'Acme Store',
'favicon' => $base64Image,
]);
$response->assertCreated();
$tenant = Tenant::query()->with('headerLogo')->where('codigo', 'acme')->firstOrFail();
$tenant = Tenant::query()->with(['headerLogo', 'favicon'])->where('codigo', 'acme')->firstOrFail();
$this->assertNotNull($tenant->header_logo_id);
$this->assertNotNull($tenant->favicon_id);
$headerUrl = $response->json('data.header_logo');
$faviconUrl = $response->json('data.favicon');
$response->assertJsonPath('data.site_title', 'Acme Store');
$this->assertStringContainsString($tenant->headerLogo->key, $headerUrl);
$this->assertStringContainsString($tenant->favicon->key, $faviconUrl);
$this->assertTrue(
str_contains($headerUrl, 'Expires=') || str_contains($headerUrl, 'expiration=') || str_contains($headerUrl, 'X-Amz-Expires=')
);
$this->assertDatabaseHas('attachments', ['key' => $tenant->headerLogo->key]);
$this->assertDatabaseHas('attachments', ['key' => $tenant->favicon->key]);
}
}

View File

@@ -32,20 +32,26 @@ class WebsiteTypeServiceTest extends TestCase
'background_color' => '#f8f8f8',
'border_color' => '#dddddd',
'login_header_footer_color' => '#333333',
'site_title' => 'Marketplace Central',
'site_logo' => UploadedFile::fake()->image('site-logo.png'),
'footer_logo' => UploadedFile::fake()->image('footer-logo.png'),
'favicon' => UploadedFile::fake()->image('favicon.png', 32, 32),
]);
$siteLogo = $websiteType->siteLogo()->firstOrFail();
$footerLogo = $websiteType->footerLogo()->firstOrFail();
$favicon = $websiteType->favicon()->firstOrFail();
$this->assertSame('marketplace', $websiteType->codigo);
$this->assertSame($siteLogo->id, $websiteType->site_logo);
$this->assertSame($footerLogo->id, $websiteType->footer_logo);
$this->assertSame($favicon->id, $websiteType->favicon_id);
$this->assertStringStartsWith('website-types/', $siteLogo->path);
$this->assertStringStartsWith('website-types/', $footerLogo->path);
$this->assertStringStartsWith('website-types/', $favicon->path);
Storage::disk('s3')->assertExists($siteLogo->path);
Storage::disk('s3')->assertExists($footerLogo->path);
Storage::disk('s3')->assertExists($favicon->path);
$this->assertDatabaseHas('website_type', [
'codigo' => 'marketplace',
'dominio' => 'marketplace.test',
@@ -57,8 +63,10 @@ class WebsiteTypeServiceTest extends TestCase
'background_color' => '#f8f8f8',
'border_color' => '#dddddd',
'login_header_footer_color' => '#333333',
'site_title' => 'Marketplace Central',
'site_logo' => $siteLogo->id,
'footer_logo' => $footerLogo->id,
'favicon_id' => $favicon->id,
]);
}
}