From 25891cefbc16ff9ab570b1b8b8d9d02decb5fc5d Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 23 Jul 2026 15:03:57 -0300 Subject: [PATCH] feat(tenant): add social media functionality with validation, synchronization, and tests --- .../Controllers/BootstrapTenantController.php | 2 +- .../Tenant/Controllers/TenantController.php | 8 +- .../Tenant/Requests/StoreTenantRequest.php | 8 ++ .../Tenant/Requests/UpdateTenantRequest.php | 8 ++ .../Tenant/Resources/TenantResource.php | 11 ++ app/Domains/Tenant/Services/TenantService.php | 29 ++++- .../Feature/Tenant/TenantSocialMediaTest.php | 119 ++++++++++++++++++ 7 files changed, 178 insertions(+), 7 deletions(-) diff --git a/app/Domains/Tenant/Controllers/BootstrapTenantController.php b/app/Domains/Tenant/Controllers/BootstrapTenantController.php index a4b6e12..e8d899c 100644 --- a/app/Domains/Tenant/Controllers/BootstrapTenantController.php +++ b/app/Domains/Tenant/Controllers/BootstrapTenantController.php @@ -16,7 +16,7 @@ class BootstrapTenantController extends Controller return TenantResource::make( Tenant::query() - ->with(['headerLogo', 'footerLogo', 'mainCarouselImages', 'menues']) + ->with(['headerLogo', 'footerLogo', 'mainCarouselImages', 'menues', 'socialMedia']) ->where('dominio', $dominio) ->firstOrFail() ); diff --git a/app/Domains/Tenant/Controllers/TenantController.php b/app/Domains/Tenant/Controllers/TenantController.php index 2d44e36..475ca56 100644 --- a/app/Domains/Tenant/Controllers/TenantController.php +++ b/app/Domains/Tenant/Controllers/TenantController.php @@ -19,7 +19,7 @@ class TenantController extends Controller { return TenantResource::collection( Tenant::query() - ->with(['headerLogo', 'footerLogo', 'mainCarouselImages']) + ->with(['headerLogo', 'footerLogo', 'mainCarouselImages', 'socialMedia']) ->latest() ->paginateFromRequest() )->response(); @@ -30,14 +30,14 @@ class TenantController extends Controller $tenant = $this->tenantService->create($request->validated()); return TenantResource::make( - $tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages']) + $tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages', 'socialMedia']) )->response()->setStatusCode(201); } public function show(Tenant $tenant): TenantResource { return TenantResource::make( - $tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages']) + $tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages', 'socialMedia']) ); } @@ -46,7 +46,7 @@ class TenantController extends Controller $tenant = $this->tenantService->update($tenant, $request->validated()); return TenantResource::make( - $tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages']) + $tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages', 'socialMedia']) ); } diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index e49552e..60c3b03 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -63,6 +63,14 @@ class StoreTenantRequest extends FormRequest 'hero_bg_image' => ['nullable', new ImageOrBase64Rule], 'main_carousel_images' => ['sometimes', 'array'], 'main_carousel_images.*' => ['required', 'distinct', new ImageOrBase64Rule], + 'social_media' => ['sometimes', 'array'], + 'social_media.*.code' => [ + 'required', + 'string', + 'distinct', + Rule::exists('social_media', 'code'), + ], + 'social_media.*.url' => ['required', 'url', 'max:2048'], '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 21a5571..7efd48c 100644 --- a/app/Domains/Tenant/Requests/UpdateTenantRequest.php +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -74,6 +74,14 @@ class UpdateTenantRequest extends FormRequest 'hero_bg_image' => ['nullable', new ImageOrBase64Rule], 'main_carousel_images' => ['sometimes', 'array'], 'main_carousel_images.*' => ['required', 'distinct', new ImageOrBase64Rule], + 'social_media' => ['sometimes', 'array'], + 'social_media.*.code' => [ + 'required', + 'string', + 'distinct', + Rule::exists('social_media', 'code'), + ], + 'social_media.*.url' => ['required', 'url', 'max:2048'], '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 ff32ad9..195742a 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -44,6 +44,17 @@ class TenantResource extends JsonResource ->map(fn ($attachment) => $attachment->getTemporaryUrl(1440)) ->values() ), + 'social_media' => $this->whenLoaded( + 'socialMedia', + fn () => $this->socialMedia + ->map(fn ($socialMedia) => [ + 'code' => $socialMedia->code, + 'icon' => $socialMedia->icon, + 'name' => $socialMedia->name, + 'url' => $socialMedia->pivot->url, + ]) + ->values() + ), 'menues' => $this->whenLoaded('menues'), ]; } diff --git a/app/Domains/Tenant/Services/TenantService.php b/app/Domains/Tenant/Services/TenantService.php index 62e0fe1..7c8d1f1 100644 --- a/app/Domains/Tenant/Services/TenantService.php +++ b/app/Domains/Tenant/Services/TenantService.php @@ -26,12 +26,14 @@ class TenantService $footerLogo = $data['footer_logo'] ?? null; $heroBgImage = $data['hero_bg_image'] ?? null; $mainCarouselImages = $data['main_carousel_images'] ?? []; + $socialMedia = $data['social_media'] ?? []; unset( $data['header_logo'], $data['footer_logo'], $data['hero_bg_image'], - $data['main_carousel_images'] + $data['main_carousel_images'], + $data['social_media'] ); $headerAttachmentId = null; @@ -74,6 +76,7 @@ class TenantService /** @var Tenant $tenant */ $tenant = Tenant::query()->create($data); $this->syncMainCarouselImages($tenant, $mainCarouselImages); + $this->syncSocialMedia($tenant, $socialMedia); return $tenant; }); @@ -91,16 +94,19 @@ class TenantService $hasFooterLogoKey = array_key_exists('footer_logo', $data); $hasHeroBgImageKey = array_key_exists('hero_bg_image', $data); $hasMainCarouselImagesKey = array_key_exists('main_carousel_images', $data); + $hasSocialMediaKey = array_key_exists('social_media', $data); $headerLogo = $data['header_logo'] ?? null; $footerLogo = $data['footer_logo'] ?? null; $heroBgImage = $data['hero_bg_image'] ?? null; $mainCarouselImages = $data['main_carousel_images'] ?? []; + $socialMedia = $data['social_media'] ?? []; unset( $data['header_logo'], $data['footer_logo'], $data['hero_bg_image'], - $data['main_carousel_images'] + $data['main_carousel_images'], + $data['social_media'] ); $oldHeroBgId = $tenant->hero_bg_image_id; @@ -167,6 +173,10 @@ class TenantService $this->syncMainCarouselImages($tenant, $mainCarouselImages); } + if ($hasSocialMediaKey) { + $this->syncSocialMedia($tenant, $socialMedia); + } + return $tenant; }); } @@ -186,6 +196,21 @@ class TenantService $tenant->mainCarouselImages()->sync($attachments); } + /** + * @param array $socialMedia + */ + private function syncSocialMedia(Tenant $tenant, array $socialMedia): void + { + $associations = []; + + foreach ($socialMedia as $item) { + $associations[$item['code']] = ['url' => $item['url']]; + } + + $tenant->socialMedia()->sync($associations); + $tenant->unsetRelation('socialMedia'); + } + private function resolveMainCarouselImage(mixed $image, int $order): Attachment { if (is_string($image) && Str::isUuid($image)) { diff --git a/tests/Feature/Tenant/TenantSocialMediaTest.php b/tests/Feature/Tenant/TenantSocialMediaTest.php index 388f188..d7c7679 100644 --- a/tests/Feature/Tenant/TenantSocialMediaTest.php +++ b/tests/Feature/Tenant/TenantSocialMediaTest.php @@ -76,6 +76,116 @@ class TenantSocialMediaTest extends TestCase ]); } + public function test_tenant_update_synchronizes_social_media_and_returns_pivot_urls(): void + { + $tenant = $this->createTenant(); + $instagram = $this->createSocialMedia('instagram', 'Instagram'); + $facebook = $this->createSocialMedia('facebook', 'Facebook'); + + $tenant->socialMedia()->attach($facebook->code, [ + 'url' => 'https://facebook.com/old-acme', + ]); + + $this->putJson("/api/tenants/{$tenant->codigo}", [ + 'social_media' => [ + [ + 'code' => $instagram->code, + 'url' => 'https://instagram.com/acme', + ], + ], + ]) + ->assertOk() + ->assertJsonPath('data.social_media.0.code', 'instagram') + ->assertJsonPath('data.social_media.0.icon', 'instagram') + ->assertJsonPath('data.social_media.0.name', 'Instagram') + ->assertJsonPath('data.social_media.0.url', 'https://instagram.com/acme') + ->assertJsonCount(1, 'data.social_media'); + + $this->assertDatabaseHas('tenant_social_media', [ + 'tenant_code' => $tenant->codigo, + 'social_media_code' => $instagram->code, + 'url' => 'https://instagram.com/acme', + ]); + $this->assertDatabaseMissing('tenant_social_media', [ + 'tenant_code' => $tenant->codigo, + 'social_media_code' => $facebook->code, + ]); + } + + public function test_tenant_store_synchronizes_social_media(): void + { + $instagram = $this->createSocialMedia('instagram', 'Instagram'); + $headerLogo = $this->createAttachment('new-header.png'); + $footerLogo = $this->createAttachment('new-footer.png'); + + $this->postJson('/api/tenants', [ + 'codigo' => 'new-acme', + 'nombre' => 'New Acme', + 'dominio' => 'new-acme.com', + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#333333', + 'success_color' => '#444444', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#ffffff', + 'header_logo' => $headerLogo->key, + 'footer_logo' => $footerLogo->key, + 'social_media' => [ + [ + 'code' => $instagram->code, + 'url' => 'https://instagram.com/new-acme', + ], + ], + ]) + ->assertCreated() + ->assertJsonPath('data.social_media.0.code', 'instagram') + ->assertJsonPath('data.social_media.0.url', 'https://instagram.com/new-acme'); + + $this->assertDatabaseHas('tenant_social_media', [ + 'tenant_code' => 'new-acme', + 'social_media_code' => $instagram->code, + 'url' => 'https://instagram.com/new-acme', + ]); + } + + public function test_tenant_update_can_clear_social_media(): void + { + $tenant = $this->createTenant(); + $instagram = $this->createSocialMedia('instagram', 'Instagram'); + $tenant->socialMedia()->attach($instagram->code, [ + 'url' => 'https://instagram.com/acme', + ]); + + $this->putJson("/api/tenants/{$tenant->codigo}", [ + 'social_media' => [], + ]) + ->assertOk() + ->assertJsonPath('data.social_media', []); + + $this->assertDatabaseMissing('tenant_social_media', [ + 'tenant_code' => $tenant->codigo, + ]); + } + + public function test_tenant_update_validates_social_media_codes_and_urls(): void + { + $tenant = $this->createTenant(); + + $this->putJson("/api/tenants/{$tenant->codigo}", [ + 'social_media' => [ + [ + 'code' => 'unknown', + 'url' => 'not-a-url', + ], + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors([ + 'social_media.0.code', + 'social_media.0.url', + ]); + } + private function createTenant(): Tenant { $headerLogo = $this->createAttachment('header.png'); @@ -105,4 +215,13 @@ class TenantSocialMediaTest extends TestCase 'mime_type' => 'image/png', ]); } + + private function createSocialMedia(string $code, string $name): SocialMedia + { + return SocialMedia::query()->create([ + 'code' => $code, + 'icon' => $code, + 'name' => $name, + ]); + } }