diff --git a/app/Domains/Tenant/Controllers/BootstrapTenantController.php b/app/Domains/Tenant/Controllers/BootstrapTenantController.php index dfe990e..a4b6e12 100644 --- a/app/Domains/Tenant/Controllers/BootstrapTenantController.php +++ b/app/Domains/Tenant/Controllers/BootstrapTenantController.php @@ -15,7 +15,10 @@ class BootstrapTenantController extends Controller $dominio = $request->validated('dominio'); return TenantResource::make( - Tenant::query()->with(['headerLogo', 'footerLogo', 'menues'])->where('dominio', $dominio)->firstOrFail() + Tenant::query() + ->with(['headerLogo', 'footerLogo', 'mainCarouselImages', 'menues']) + ->where('dominio', $dominio) + ->firstOrFail() ); } } diff --git a/app/Domains/Tenant/Controllers/TenantController.php b/app/Domains/Tenant/Controllers/TenantController.php index 3857d8e..2d44e36 100644 --- a/app/Domains/Tenant/Controllers/TenantController.php +++ b/app/Domains/Tenant/Controllers/TenantController.php @@ -13,32 +13,41 @@ use Illuminate\Http\Response; class TenantController extends Controller { - public function __construct(protected TenantService $tenantService) - { - } + public function __construct(protected TenantService $tenantService) {} public function index(): JsonResponse { - return TenantResource::collection(Tenant::query()->with(['headerLogo', 'footerLogo'])->latest()->paginateFromRequest())->response(); + return TenantResource::collection( + Tenant::query() + ->with(['headerLogo', 'footerLogo', 'mainCarouselImages']) + ->latest() + ->paginateFromRequest() + )->response(); } public function store(StoreTenantRequest $request): JsonResponse { $tenant = $this->tenantService->create($request->validated()); - return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']))->response()->setStatusCode(201); + return TenantResource::make( + $tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages']) + )->response()->setStatusCode(201); } public function show(Tenant $tenant): TenantResource { - return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo'])); + return TenantResource::make( + $tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages']) + ); } public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource { $tenant = $this->tenantService->update($tenant, $request->validated()); - return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo'])); + return TenantResource::make( + $tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages']) + ); } public function destroy(Tenant $tenant): Response diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index 705a0be..938797d 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -73,6 +73,22 @@ class Tenant extends Model return $this->belongsTo(Attachment::class, 'hero_bg_image_id'); } + /** + * @return BelongsToMany + */ + public function mainCarouselImages(): BelongsToMany + { + return $this->belongsToMany( + Attachment::class, + 'tenant_main_carousel_images', + 'tenant_id', + 'attachment_id' + ) + ->withPivot('orden') + ->withTimestamps() + ->orderByPivot('orden'); + } + public function catalogItems(): HasMany { return $this->hasMany(CatalogItem::class, 'tenant_code', 'codigo'); diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index 9ee976c..ae0655e 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -62,6 +62,8 @@ class StoreTenantRequest extends FormRequest 'header_logo' => $logoRule, 'footer_logo' => $logoRule, 'hero_bg_image' => ['nullable', new ImageOrBase64Rule()], + 'main_carousel_images' => ['sometimes', 'array'], + 'main_carousel_images.*' => ['required', 'distinct', new ImageOrBase64Rule()], 'hero_config' => ['nullable', 'array'], 'hero_config.title_html' => ['nullable', 'string'], 'hero_config.description_html' => ['nullable', 'string'], diff --git a/app/Domains/Tenant/Requests/UpdateTenantRequest.php b/app/Domains/Tenant/Requests/UpdateTenantRequest.php index 219761e..1338754 100644 --- a/app/Domains/Tenant/Requests/UpdateTenantRequest.php +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -73,6 +73,8 @@ class UpdateTenantRequest extends FormRequest 'header_logo' => $logoRule, 'footer_logo' => $logoRule, 'hero_bg_image' => ['nullable', new ImageOrBase64Rule()], + 'main_carousel_images' => ['sometimes', 'array'], + 'main_carousel_images.*' => ['required', 'distinct', new ImageOrBase64Rule()], 'hero_config' => ['nullable', 'array'], 'hero_config.title_html' => ['nullable', 'string'], 'hero_config.description_html' => ['nullable', 'string'], diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index 9fae254..ff32ad9 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -2,11 +2,12 @@ namespace App\Domains\Tenant\Resources; +use App\Domains\Tenant\Models\Tenant; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** - * @mixin \App\Domains\Tenant\Models\Tenant + * @mixin Tenant */ class TenantResource extends JsonResource { @@ -34,9 +35,15 @@ class TenantResource extends JsonResource 'footer_bg_color' => $this->footer_bg_color, // 1 day 'header_logo' => $this->headerLogo?->getTemporaryUrl(1440), - 'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440 ), + 'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440), 'hero_config' => $heroConfig, 'event_config' => $this->event_config, + 'main_carousel_images' => $this->whenLoaded( + 'mainCarouselImages', + fn () => $this->mainCarouselImages + ->map(fn ($attachment) => $attachment->getTemporaryUrl(1440)) + ->values() + ), 'menues' => $this->whenLoaded('menues'), ]; } diff --git a/app/Domains/Tenant/Services/TenantService.php b/app/Domains/Tenant/Services/TenantService.php index 57e62f1..8a53699 100644 --- a/app/Domains/Tenant/Services/TenantService.php +++ b/app/Domains/Tenant/Services/TenantService.php @@ -2,10 +2,13 @@ namespace App\Domains\Tenant\Services; +use App\Domains\Attachable\Enums\AttachmentType; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Services\AttachmentService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; class TenantService { @@ -25,8 +28,14 @@ class TenantService $headerLogo = $data['header_logo'] ?? null; $footerLogo = $data['footer_logo'] ?? null; $heroBgImage = $data['hero_bg_image'] ?? null; + $mainCarouselImages = $data['main_carousel_images'] ?? []; - unset($data['header_logo'], $data['footer_logo'], $data['hero_bg_image']); + unset( + $data['header_logo'], + $data['footer_logo'], + $data['hero_bg_image'], + $data['main_carousel_images'] + ); $headerAttachmentId = null; if ($headerLogo) { @@ -67,6 +76,7 @@ class TenantService /** @var Tenant $tenant */ $tenant = Tenant::query()->create($data); + $this->syncMainCarouselImages($tenant, $mainCarouselImages); return $tenant; }); @@ -85,11 +95,18 @@ class TenantService $hasHeaderLogoKey = array_key_exists('header_logo', $data); $hasFooterLogoKey = array_key_exists('footer_logo', $data); $hasHeroBgImageKey = array_key_exists('hero_bg_image', $data); + $hasMainCarouselImagesKey = array_key_exists('main_carousel_images', $data); $headerLogo = $data['header_logo'] ?? null; $footerLogo = $data['footer_logo'] ?? null; $heroBgImage = $data['hero_bg_image'] ?? null; + $mainCarouselImages = $data['main_carousel_images'] ?? []; - unset($data['header_logo'], $data['footer_logo'], $data['hero_bg_image']); + unset( + $data['header_logo'], + $data['footer_logo'], + $data['hero_bg_image'], + $data['main_carousel_images'] + ); $oldHeroBgId = $tenant->hero_bg_image_id; @@ -151,7 +168,48 @@ class TenantService $tenant->save(); + if ($hasMainCarouselImagesKey) { + $this->syncMainCarouselImages($tenant, $mainCarouselImages); + } + return $tenant; }); } + + /** + * @param array $images + */ + private function syncMainCarouselImages(Tenant $tenant, array $images): void + { + $attachments = []; + + foreach (array_values($images) as $order => $image) { + $attachment = $this->resolveMainCarouselImage($image, $order); + $attachments[$attachment->id] = ['orden' => $order]; + } + + $tenant->mainCarouselImages()->sync($attachments); + } + + private function resolveMainCarouselImage(mixed $image, int $order): Attachment + { + if (is_string($image) && Str::isUuid($image)) { + $attachment = Attachment::query() + ->where('key', $image) + ->where('type', AttachmentType::Image->value) + ->first(); + + if ($attachment === null) { + throw ValidationException::withMessages([ + "main_carousel_images.{$order}" => [ + 'La imagen indicada no existe o no es un attachment de tipo imagen.', + ], + ]); + } + + return $attachment; + } + + return $this->attachmentService->store($image, 'tenants/main-carousel'); + } } diff --git a/database/migrations/2026_07_23_000100_create_tenant_main_carousel_images_table.php b/database/migrations/2026_07_23_000100_create_tenant_main_carousel_images_table.php new file mode 100644 index 0000000..e527af2 --- /dev/null +++ b/database/migrations/2026_07_23_000100_create_tenant_main_carousel_images_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('tenant_id') + ->constrained('tenants') + ->cascadeOnDelete(); + $table->foreignId('attachment_id') + ->constrained('attachments') + ->cascadeOnDelete(); + $table->unsignedInteger('orden')->default(0); + $table->timestamps(); + + $table->unique(['tenant_id', 'attachment_id']); + $table->index(['tenant_id', 'orden']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tenant_main_carousel_images'); + } +}; diff --git a/tests/Feature/Tenant/TenantMainCarouselImagesTest.php b/tests/Feature/Tenant/TenantMainCarouselImagesTest.php new file mode 100644 index 0000000..32a691a --- /dev/null +++ b/tests/Feature/Tenant/TenantMainCarouselImagesTest.php @@ -0,0 +1,101 @@ +createTenant(); + $firstAttachment = $this->createAttachment('first.png'); + $secondAttachment = $this->createAttachment('second.png'); + + $tenant->mainCarouselImages()->attach([ + $secondAttachment->id => ['orden' => 2], + $firstAttachment->id => ['orden' => 1], + ]); + + $attachments = $tenant->mainCarouselImages()->get(); + + $this->assertCount(2, $attachments); + $this->assertTrue($attachments[0]->is($firstAttachment)); + $this->assertTrue($attachments[1]->is($secondAttachment)); + $this->assertSame([1, 2], $attachments->pluck('pivot.orden')->all()); + } + + public function test_bootstrap_returns_all_main_carousel_image_urls_in_order(): void + { + $tenant = $this->createTenant(); + $firstAttachment = $this->createAttachment('first.png'); + $secondAttachment = $this->createAttachment('second.png'); + + $tenant->mainCarouselImages()->attach([ + $secondAttachment->id => ['orden' => 20], + $firstAttachment->id => ['orden' => 10], + ]); + + $response = $this->getJson('/api/tenants/bootstrap/acme.com'); + + $response + ->assertOk() + ->assertJsonCount(2, 'data.main_carousel_images'); + + $urls = $response->json('data.main_carousel_images'); + + $this->assertStringContainsString($firstAttachment->key, $urls[0]); + $this->assertStringContainsString($secondAttachment->key, $urls[1]); + } + + public function test_show_returns_an_empty_main_carousel_images_array_when_the_tenant_has_no_images(): void + { + $tenant = $this->createTenant(); + + $this->getJson("/api/tenants/{$tenant->codigo}") + ->assertOk() + ->assertJsonPath('data.main_carousel_images', []); + } + + private function createTenant(): Tenant + { + $headerLogo = $this->createAttachment('header.png'); + $footerLogo = $this->createAttachment('footer.png'); + + return Tenant::query()->create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.com', + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#333333', + 'success_color' => '#444444', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#ffffff', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $filename): Attachment + { + $key = (string) Str::uuid(); + + return Attachment::query()->create([ + 'key' => $key, + 'path' => "tenants/main-carousel/{$key}.png", + 'filename' => $filename, + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + 'extension' => 'png', + 'size' => 100, + ]); + } +}