diff --git a/app/Domains/Tenant/Controllers/BootstrapTenantController.php b/app/Domains/Tenant/Controllers/BootstrapTenantController.php index 1767580..4d7402a 100644 --- a/app/Domains/Tenant/Controllers/BootstrapTenantController.php +++ b/app/Domains/Tenant/Controllers/BootstrapTenantController.php @@ -5,26 +5,30 @@ namespace App\Domains\Tenant\Controllers; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Requests\BootstrapTenantRequest; use App\Domains\Tenant\Resources\TenantResource; +use App\Domains\Tenant\Services\TenantInformationService; use App\Http\Controllers\Controller; class BootstrapTenantController extends Controller { + public function __construct( + protected TenantInformationService $tenantInformationService + ) {} + public function __invoke(BootstrapTenantRequest $request): TenantResource { /** @var string $dominio */ $dominio = $request->validated('dominio'); return TenantResource::make( - Tenant::query() - ->with([ - 'headerLogo', - 'footerLogo', + $this->tenantInformationService->load( + Tenant::query() + ->where('dominio', $dominio) + ->firstOrFail(), + [ 'menues', - 'socialMedia', 'categories' => fn ($query) => $query->orderBy('nombre'), - ]) - ->where('dominio', $dominio) - ->firstOrFail() + ] + ) ); } } diff --git a/app/Domains/Tenant/Controllers/TenantController.php b/app/Domains/Tenant/Controllers/TenantController.php index 24c7f67..5b105af 100644 --- a/app/Domains/Tenant/Controllers/TenantController.php +++ b/app/Domains/Tenant/Controllers/TenantController.php @@ -6,6 +6,7 @@ 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\TenantInformationService; use App\Domains\Tenant\Services\TenantService; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; @@ -13,16 +14,20 @@ use Illuminate\Http\Response; class TenantController extends Controller { - public function __construct(protected TenantService $tenantService) {} + public function __construct( + protected TenantService $tenantService, + protected TenantInformationService $tenantInformationService, + ) {} public function index(): JsonResponse { - return TenantResource::collection( - Tenant::query() - ->with(['headerLogo', 'footerLogo', 'socialMedia']) - ->latest() - ->paginateFromRequest() - )->response(); + $tenants = Tenant::query() + ->latest() + ->paginateFromRequest(); + + $this->tenantInformationService->loadMany($tenants->getCollection()); + + return TenantResource::collection($tenants)->response(); } public function store(StoreTenantRequest $request): JsonResponse @@ -30,20 +35,14 @@ class TenantController extends Controller $tenant = $this->tenantService->create($request->validated()); return TenantResource::make( - $tenant->loadMissing([ - 'headerLogo', - 'footerLogo', - 'socialMedia', - 'websiteType', - 'websiteExtras.websiteTypeExtra', - ]) + $this->tenantInformationService->load($tenant) )->response()->setStatusCode(201); } public function show(Tenant $tenant): TenantResource { return TenantResource::make( - $tenant->loadMissing(['headerLogo', 'footerLogo', 'socialMedia']) + $this->tenantInformationService->load($tenant) ); } @@ -52,7 +51,7 @@ class TenantController extends Controller $tenant = $this->tenantService->update($tenant, $request->validated()); return TenantResource::make( - $tenant->loadMissing(['headerLogo', 'footerLogo', 'socialMedia']) + $this->tenantInformationService->load($tenant) ); } diff --git a/app/Domains/Tenant/Models/WebsiteExtra.php b/app/Domains/Tenant/Models/WebsiteExtra.php index 5cd2cbe..9c67c6b 100644 --- a/app/Domains/Tenant/Models/WebsiteExtra.php +++ b/app/Domains/Tenant/Models/WebsiteExtra.php @@ -18,6 +18,10 @@ class WebsiteExtra extends Model protected $table = 'websites_extras'; + private mixed $resolvedConfig = null; + + private bool $hasResolvedConfig = false; + /** * @return array */ @@ -44,4 +48,17 @@ class WebsiteExtra extends Model { return $this->belongsTo(WebsiteTypeExtra::class, 'website_type_extra_id'); } + + public function setResolvedConfig(mixed $config): self + { + $this->resolvedConfig = $config; + $this->hasResolvedConfig = true; + + return $this; + } + + public function resolvedConfig(): mixed + { + return $this->hasResolvedConfig ? $this->resolvedConfig : $this->config; + } } diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index 28e2c7d..8ed48d9 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -2,6 +2,7 @@ namespace App\Domains\Tenant\Resources; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Catalog\Models\Category; use App\Domains\Menu\Models\Menu; use App\Domains\Tenant\Models\Tenant; @@ -40,10 +41,11 @@ class TenantResource extends JsonResource ), 'extras' => $this->whenLoaded( 'websiteExtras', - fn () => $this->websiteExtras - ->mapWithKeys(fn ($extra) => [ - $extra->websiteTypeExtra->nombre => $extra->config, - ]) + fn () => $this->websiteExtras->mapWithKeys(fn ($extra) => [ + $extra->websiteTypeExtra->nombre => $this->formatExtraConfig( + $extra->resolvedConfig() + ), + ]) ), // 1 day 'header_logo' => $this->headerLogo?->getTemporaryUrl(1440), @@ -73,6 +75,23 @@ class TenantResource extends JsonResource ]; } + private function formatExtraConfig(mixed $value): mixed + { + if ($value instanceof Attachment) { + return $value->getTemporaryUrl(1440); + } + + if (! is_array($value)) { + return $value; + } + + foreach ($value as $key => $item) { + $value[$key] = $this->formatExtraConfig($item); + } + + return $value; + } + /** * @param Collection $categories * @return Collection> diff --git a/app/Domains/Tenant/Services/TenantInformationService.php b/app/Domains/Tenant/Services/TenantInformationService.php new file mode 100644 index 0000000..ed64dd9 --- /dev/null +++ b/app/Domains/Tenant/Services/TenantInformationService.php @@ -0,0 +1,177 @@ + $relations + */ + public function load(Tenant $tenant, array $relations = []): Tenant + { + $this->loadMany(new EloquentCollection([$tenant]), $relations); + + return $tenant; + } + + /** + * @param EloquentCollection $tenants + * @param array $relations + * @return EloquentCollection + */ + public function loadMany( + EloquentCollection $tenants, + array $relations = [] + ): EloquentCollection { + $tenants->loadMissing([ + ...self::DEFAULT_RELATIONS, + ...$relations, + ]); + + $websiteExtras = $tenants + ->flatMap(fn (Tenant $tenant): Collection => $tenant->websiteExtras); + + $this->resolveWebsiteExtraAttachments($websiteExtras); + + return $tenants; + } + + /** + * Replace attachment IDs with their models in each extra's resolved config. + * + * @param Collection $websiteExtras + */ + private function resolveWebsiteExtraAttachments(Collection $websiteExtras): void + { + $attachmentIds = $websiteExtras + ->flatMap(function (WebsiteExtra $extra): array { + $ids = []; + + foreach ($this->attachmentTransformPaths($extra) as $path) { + $ids = [ + ...$ids, + ...$this->valuesAtPath($extra->config, $this->pathSegments($path)), + ]; + } + + return $ids; + }) + ->filter(fn (mixed $id): bool => is_int($id) || ctype_digit((string) $id)) + ->map(fn (mixed $id): int => (int) $id) + ->unique() + ->values(); + + $attachments = Attachment::query() + ->whereIn('id', $attachmentIds) + ->get() + ->keyBy('id'); + + foreach ($websiteExtras as $extra) { + $config = $extra->config; + + foreach ($this->attachmentTransformPaths($extra) as $path) { + $config = $this->replaceAtPath( + $config, + $this->pathSegments($path), + fn (mixed $attachmentId): ?Attachment => $attachments->get((int) $attachmentId) + ); + } + + $extra->setResolvedConfig($config); + } + } + + /** + * @return array + */ + private function attachmentTransformPaths(WebsiteExtra $extra): array + { + return collect($extra->websiteTypeExtra->config_schema['transforms'] ?? []) + ->filter( + fn (mixed $transform): bool => is_array($transform) + && ($transform['handler'] ?? null) === 'attachment' + ) + ->keys() + ->all(); + } + + /** + * @return array + */ + private function pathSegments(string $path): array + { + $path = ltrim($path, '$'); + $path = ltrim($path, '.'); + + return $path === '' ? [] : explode('.', $path); + } + + /** + * @return array + */ + private function valuesAtPath(mixed $value, array $segments): array + { + if ($segments === []) { + return [$value]; + } + + if (! is_array($value)) { + return []; + } + + $segment = array_shift($segments); + + if ($segment === '*') { + return collect($value) + ->flatMap(fn (mixed $item): array => $this->valuesAtPath($item, $segments)) + ->all(); + } + + if (! array_key_exists($segment, $value)) { + return []; + } + + return $this->valuesAtPath($value[$segment], $segments); + } + + private function replaceAtPath(mixed $value, array $segments, callable $replace): mixed + { + if ($segments === []) { + return $value === null ? null : $replace($value); + } + + if (! is_array($value)) { + return $value; + } + + $segment = array_shift($segments); + + if ($segment === '*') { + foreach ($value as $key => $item) { + $value[$key] = $this->replaceAtPath($item, $segments, $replace); + } + + return $value; + } + + if (array_key_exists($segment, $value)) { + $value[$segment] = $this->replaceAtPath($value[$segment], $segments, $replace); + } + + return $value; + } +} diff --git a/database/seeders/TenantSeeder.php b/database/seeders/TenantSeeder.php index 8db49a2..3600258 100644 --- a/database/seeders/TenantSeeder.php +++ b/database/seeders/TenantSeeder.php @@ -2,6 +2,7 @@ namespace Database\Seeders; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Services\AttachmentService; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Services\TenantService; @@ -58,6 +59,35 @@ class TenantSeeder extends Seeder 'header_logo' => $this->uploadedImage('images/sonder_header.png', 'sonder_header.png'), 'footer_logo' => $this->uploadedImage('images/sonder_footer.png', 'sonder_footer.png'), 'social_media' => self::SOCIAL_MEDIA, + 'website_type_code' => 'shopit', + 'extras' => [ + 'carousel' => [ + $this->uploadedImage( + 'images/sonder-main-carousel/01-urban-team.png', + '01-urban-team.png', + ), + $this->uploadedImage( + 'images/sonder-main-carousel/02-running-shoes.png', + '02-running-shoes.png', + ), + $this->uploadedImage( + 'images/sonder-main-carousel/03-streetwear.png', + '03-streetwear.png', + ), + $this->uploadedImage( + 'images/sonder-main-carousel/04-football-training.png', + '04-football-training.png', + ), + $this->uploadedImage( + 'images/sonder-main-carousel/05-activewear-essentials.png', + '05-activewear-essentials.png', + ), + $this->uploadedImage( + 'images/sonder-main-carousel/06-city-runners.png', + '06-city-runners.png', + ), + ], + ], ]); $this->deleteTenant('fiesta_futbol_infantil'); @@ -86,6 +116,27 @@ class TenantSeeder extends Seeder 'futbol_infantil_footer.png', ), 'social_media' => self::SOCIAL_MEDIA, + 'website_type_code' => 'onticket', + 'extras' => [ + 'heroConfig' => [ + 'title_html' => '

Fiesta Fútbol Infantil

', + 'description_html' => '

Viví una jornada inolvidable de fútbol infantil.

', + 'button_text' => 'Comprar entradas', + 'button_href' => '/tickets', + 'background_image_id' => $this->uploadedImage( + 'images/futbol_infantil_hero.jpg', + 'futbol_infantil_hero.jpg', + ), + ], + 'eventConfig' => [ + 'title' => 'Fiesta Fútbol Infantil', + 'location' => 'Rosario, Santa Fe', + 'dates' => [ + '2026-12-05', + '2026-12-06', + ], + ], + ], ]); } @@ -101,6 +152,24 @@ class TenantSeeder extends Seeder $attachmentService = app(AttachmentService::class); + $extraAttachments = Attachment::query() + ->where('path', 'like', 'tenants/%/extras/%') + ->get() + ->filter( + fn (Attachment $attachment): bool => str_starts_with( + $attachment->path, + "tenants/{$codigo}/extras/" + ) + ); + + foreach ($extraAttachments as $attachment) { + try { + $attachmentService->delete($attachment); + } catch (Throwable) { + // Ignore cleanup errors while recreating demo data. + } + } + if ($tenant->headerLogo) { try { $attachmentService->delete($tenant->headerLogo); @@ -128,10 +197,17 @@ class TenantSeeder extends Seeder throw new RuntimeException("Image not found at path: {$path}"); } + $mimeType = match (strtolower(pathinfo($filename, PATHINFO_EXTENSION))) { + 'jpg', 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + default => 'image/png', + }; + return new UploadedFile( $path, $filename, - 'image/png', + $mimeType, null, true, ); diff --git a/tests/Feature/Seeders/TenantSeederTest.php b/tests/Feature/Seeders/TenantSeederTest.php index f6b110c..784fa54 100644 --- a/tests/Feature/Seeders/TenantSeederTest.php +++ b/tests/Feature/Seeders/TenantSeederTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Seeders; use App\Domains\Tenant\Models\Tenant; use Database\Seeders\SocialMediaSeeder; use Database\Seeders\TenantSeeder; +use Database\Seeders\WebsiteTypeSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Storage; use Tests\TestCase; @@ -18,6 +19,7 @@ class TenantSeederTest extends TestCase Storage::fake('s3'); $this->seed([ + WebsiteTypeSeeder::class, SocialMediaSeeder::class, TenantSeeder::class, ]); @@ -42,4 +44,65 @@ class TenantSeederTest extends TestCase $this->assertSame([0, 1, 2, 3], $socialMedia->pluck('pivot.orden')->all()); } } + + public function test_it_assigns_each_tenant_its_website_type_and_extras(): void + { + Storage::fake('s3'); + + $this->seed([ + WebsiteTypeSeeder::class, + SocialMediaSeeder::class, + TenantSeeder::class, + ]); + + $sonder = Tenant::query() + ->where('codigo', 'sonder') + ->with('websiteExtras.websiteTypeExtra') + ->sole(); + + $this->assertSame('shopit', $sonder->website_type_code); + + $carousel = $sonder->websiteExtras + ->firstWhere('websiteTypeExtra.nombre', 'carousel'); + + $this->assertNotNull($carousel); + $this->assertCount(6, $carousel->config); + + foreach ($carousel->config as $attachmentId) { + $this->assertDatabaseHas('attachments', [ + 'id' => $attachmentId, + 'type' => 'image', + ]); + } + + $fiesta = Tenant::query() + ->where('codigo', 'fiesta_futbol_infantil') + ->with('websiteExtras.websiteTypeExtra') + ->sole(); + + $this->assertSame('onticket', $fiesta->website_type_code); + + $extras = $fiesta->websiteExtras->keyBy('websiteTypeExtra.nombre'); + $this->assertEqualsCanonicalizing( + ['heroConfig', 'eventConfig'], + $extras->keys()->all(), + ); + + $heroConfig = $extras->get('heroConfig')->config; + $this->assertSame('

Fiesta Fútbol Infantil

', $heroConfig['title_html']); + $this->assertIsInt($heroConfig['background_image_id']); + $this->assertDatabaseHas('attachments', [ + 'id' => $heroConfig['background_image_id'], + 'type' => 'image', + ]); + + $this->assertSame([ + 'title' => 'Fiesta Fútbol Infantil', + 'location' => 'Rosario, Santa Fe', + 'dates' => [ + '2026-12-05', + '2026-12-06', + ], + ], $extras->get('eventConfig')->config); + } } diff --git a/tests/Feature/Tenant/StoreTenantWithExtrasTest.php b/tests/Feature/Tenant/StoreTenantWithExtrasTest.php index 58b72e0..2b71b44 100644 --- a/tests/Feature/Tenant/StoreTenantWithExtrasTest.php +++ b/tests/Feature/Tenant/StoreTenantWithExtrasTest.php @@ -2,7 +2,9 @@ namespace Tests\Feature\Tenant; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Tenant\Services\TenantInformationService; use Database\Seeders\WebsiteTypeSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Storage; @@ -99,10 +101,10 @@ class StoreTenantWithExtrasTest extends TestCase $response->assertCreated(); - $config = Tenant::query() + $tenant = Tenant::query() ->where('codigo', 'festival') - ->sole() - ->websiteExtras() + ->sole(); + $config = $tenant->websiteExtras() ->sole() ->config; @@ -112,7 +114,53 @@ class StoreTenantWithExtrasTest extends TestCase 'id' => $config[0], 'type' => 'image', ]); - $response->assertJsonPath('data.extras.carousel.0', $config[0]); + + $attachment = Attachment::query()->findOrFail($config[0]); + $carouselUrl = $response->json('data.extras.carousel.0'); + + $this->assertIsString($carouselUrl); + $this->assertStringContainsString($attachment->key, $carouselUrl); + + app(TenantInformationService::class)->load($tenant); + + $this->assertInstanceOf( + Attachment::class, + $tenant->websiteExtras->sole()->resolvedConfig()[0] + ); + } + + public function test_it_returns_scalar_attachment_fields_as_temporary_urls(): void + { + $image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + $response = $this->postJson('/api/tenants', array_merge($this->tenantData(), [ + 'website_type_code' => 'onticket', + 'extras' => [ + 'heroConfig' => [ + 'title_html' => '

Festival

', + 'background_image_id' => $image, + ], + ], + ])); + + $response->assertCreated(); + + $heroConfig = Tenant::query() + ->where('codigo', 'festival') + ->sole() + ->websiteExtras() + ->whereHas( + 'websiteTypeExtra', + fn ($query) => $query->where('nombre', 'heroConfig') + ) + ->sole() + ->config; + + $attachment = Attachment::query()->findOrFail($heroConfig['background_image_id']); + $backgroundUrl = $response->json('data.extras.heroConfig.background_image_id'); + + $this->assertIsString($backgroundUrl); + $this->assertStringContainsString($attachment->key, $backgroundUrl); } /**