From 370834856b0e1ad9b391a7c88a481335f8d461f5 Mon Sep 17 00:00:00 2001 From: nahu Date: Mon, 28 Sep 2026 17:37:14 +0000 Subject: [PATCH 01/18] feat(website-assets): update ShopIt website assets and adjust favicon paths --- ..._08_26_000000_set_website_type_favicon.php | 2 +- ...9_28_000000_update_shopit_admin_assets.php | 47 ++++++++++++++++++ database/seeders/WebsiteTypeSeeder.php | 43 +++++++++++++--- .../{ => onticket}/onticket_favicon.svg | 0 .../{ => onticket}/onticket_footer_logo.png | Bin .../{ => onticket}/onticket_logo.png | Bin .../website_types/shopit/shopit-favicon.svg | 10 ++++ .../shopit/shopit_footer_logo.png | Bin 0 -> 1104 bytes .../shopit_footer_logo.png:Zone.Identifier | Bin 0 -> 25 bytes .../website_types/shopit/shopit_logo.png | Bin 0 -> 2869 bytes .../shopit/shopit_logo.png:Zone.Identifier | Bin 0 -> 25 bytes .../Migrations/SetWebsiteTypeFaviconTest.php | 2 +- .../Feature/Seeders/WebsiteTypeSeederTest.php | 15 +++--- 13 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 database/migrations/2026_09_28_000000_update_shopit_admin_assets.php rename public/images/website_types/{ => onticket}/onticket_favicon.svg (100%) rename public/images/website_types/{ => onticket}/onticket_footer_logo.png (100%) rename public/images/website_types/{ => onticket}/onticket_logo.png (100%) create mode 100644 public/images/website_types/shopit/shopit-favicon.svg create mode 100644 public/images/website_types/shopit/shopit_footer_logo.png create mode 100644 public/images/website_types/shopit/shopit_footer_logo.png:Zone.Identifier create mode 100644 public/images/website_types/shopit/shopit_logo.png create mode 100644 public/images/website_types/shopit/shopit_logo.png:Zone.Identifier 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 index e3699ed6..9669433c 100644 --- a/database/migrations/2026_08_26_000000_set_website_type_favicon.php +++ b/database/migrations/2026_08_26_000000_set_website_type_favicon.php @@ -41,7 +41,7 @@ return new class extends Migration return; } - $sourcePath = public_path('images/website_types/'.self::FILENAME); + $sourcePath = public_path('images/website_types/onticket/'.self::FILENAME); if (! is_file($sourcePath)) { throw new RuntimeException("Favicon not found at path: {$sourcePath}"); diff --git a/database/migrations/2026_09_28_000000_update_shopit_admin_assets.php b/database/migrations/2026_09_28_000000_update_shopit_admin_assets.php new file mode 100644 index 00000000..8003964c --- /dev/null +++ b/database/migrations/2026_09_28_000000_update_shopit_admin_assets.php @@ -0,0 +1,47 @@ +environment('testing')) { + Storage::fake('s3'); + } + + if (! AdminWebsiteType::query()->where('codigo', 'shopit')->exists()) { + return; + } + + app(AdminWebsiteTypeService::class)->updateOrCreate( + ['codigo' => 'shopit'], + [ + 'primary_color' => '#2FD3AC', + 'site_logo' => $this->uploadedImage('shopit_logo.png', 'image/png'), + 'footer_logo' => $this->uploadedImage('shopit_footer_logo.png', 'image/png'), + 'favicon' => $this->uploadedImage('shopit-favicon.svg', 'image/svg+xml'), + ], + ); + } + + public function down(): void + { + // Brand assets are operational data and are intentionally preserved. + } + + private function uploadedImage(string $filename, string $mimeType): UploadedFile + { + $path = public_path("images/website_types/shopit/{$filename}"); + + if (! is_file($path)) { + throw new RuntimeException("ShopIt image not found at path: {$path}"); + } + + return new UploadedFile($path, $filename, $mimeType, null, true); + } +}; diff --git a/database/seeders/WebsiteTypeSeeder.php b/database/seeders/WebsiteTypeSeeder.php index 230a876f..1379136a 100644 --- a/database/seeders/WebsiteTypeSeeder.php +++ b/database/seeders/WebsiteTypeSeeder.php @@ -40,7 +40,7 @@ class WebsiteTypeSeeder extends Seeder public function run(): void { - $shopIt = $this->websiteTypeService->updateOrCreate( + $this->websiteTypeService->updateOrCreate( ['codigo' => 'shopit'], [ 'nombre' => 'ShopIt', @@ -48,9 +48,10 @@ class WebsiteTypeSeeder extends Seeder 'scanner_domain' => 'scanner.localhost', 'site_title' => 'ShopIt', ...self::PRESENTATION, - 'site_logo' => $this->onTicketLogo(), - 'footer_logo' => $this->onTicketFooterLogo(), - 'favicon' => $this->onTicketFavicon(), + 'primary_color' => '#2FD3AC', + 'site_logo' => $this->shopItLogo(), + 'footer_logo' => $this->shopItFooterLogo(), + 'favicon' => $this->shopItFavicon(), ], ); @@ -83,7 +84,7 @@ class WebsiteTypeSeeder extends Seeder ...self::PRESENTATION, 'site_logo' => $this->onTicketLogo(), 'footer_logo' => $this->onTicketFooterLogo(), - 'favicon' => $shopIt->favicon()->firstOrFail()->key, + 'favicon' => $this->onTicketFavicon(), ], ); @@ -204,7 +205,7 @@ class WebsiteTypeSeeder extends Seeder private function onTicketLogo(): UploadedFile { - $path = public_path('images/website_types/onticket_logo.png'); + $path = public_path('images/website_types/onticket/onticket_logo.png'); if (! file_exists($path)) { throw new RuntimeException("OnTicket logo not found at path: {$path}"); @@ -221,7 +222,7 @@ class WebsiteTypeSeeder extends Seeder private function onTicketFooterLogo(): UploadedFile { - $path = public_path('images/website_types/onticket_footer_logo.png'); + $path = public_path('images/website_types/onticket/onticket_footer_logo.png'); if (! file_exists($path)) { throw new RuntimeException("OnTicket footer logo not found at path: {$path}"); @@ -238,7 +239,7 @@ class WebsiteTypeSeeder extends Seeder private function onTicketFavicon(): UploadedFile { - $path = public_path('images/website_types/onticket_favicon.svg'); + $path = public_path('images/website_types/onticket/onticket_favicon.svg'); if (! file_exists($path)) { throw new RuntimeException("OnTicket favicon not found at path: {$path}"); @@ -252,4 +253,30 @@ class WebsiteTypeSeeder extends Seeder true, ); } + + private function shopItLogo(): UploadedFile + { + return $this->uploadedImage('shopit', 'shopit_logo.png', 'image/png'); + } + + private function shopItFooterLogo(): UploadedFile + { + return $this->uploadedImage('shopit', 'shopit_footer_logo.png', 'image/png'); + } + + private function shopItFavicon(): UploadedFile + { + return $this->uploadedImage('shopit', 'shopit-favicon.svg', 'image/svg+xml'); + } + + private function uploadedImage(string $websiteType, string $filename, string $mimeType): UploadedFile + { + $path = public_path("images/website_types/{$websiteType}/{$filename}"); + + if (! file_exists($path)) { + throw new RuntimeException("Website type image not found at path: {$path}"); + } + + return new UploadedFile($path, $filename, $mimeType, null, true); + } } diff --git a/public/images/website_types/onticket_favicon.svg b/public/images/website_types/onticket/onticket_favicon.svg similarity index 100% rename from public/images/website_types/onticket_favicon.svg rename to public/images/website_types/onticket/onticket_favicon.svg diff --git a/public/images/website_types/onticket_footer_logo.png b/public/images/website_types/onticket/onticket_footer_logo.png similarity index 100% rename from public/images/website_types/onticket_footer_logo.png rename to public/images/website_types/onticket/onticket_footer_logo.png diff --git a/public/images/website_types/onticket_logo.png b/public/images/website_types/onticket/onticket_logo.png similarity index 100% rename from public/images/website_types/onticket_logo.png rename to public/images/website_types/onticket/onticket_logo.png diff --git a/public/images/website_types/shopit/shopit-favicon.svg b/public/images/website_types/shopit/shopit-favicon.svg new file mode 100644 index 00000000..3443faac --- /dev/null +++ b/public/images/website_types/shopit/shopit-favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + +Layer 1 diff --git a/public/images/website_types/shopit/shopit_footer_logo.png b/public/images/website_types/shopit/shopit_footer_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b8b6ae1d705a09ed71bb6bc868d92e26d79d611c GIT binary patch literal 1104 zcmV-W1h4yvP)E ziSAcn*#(I%>E~ynF;SBkSHdxYl}xzZO3rI++&VID3K@{oU0)J?CD9#jQ@N4rAgeYI zci+^Ut&uIdlCUo%JPVbIn%rmP`3Sqw_UtB+JMhd~@H9lXURxRA&7$Oo1MOEx2s8K> zz-KcNbh2egSu-975|FQq*xBUvlIR-Z_v&kgbhN;DlHLZVpg(Ylo%yvMbuV-&7zj{-sa0@uLb++o~xh(sN`ZL>mODI$!Hu5bO1>%UjI8m zo8UV{i(b7Qlk(-w?%|SczP#s>JR8M264$MLw^iq8AoK7y=Oce)d+Q8@7~FTOlXSE- zB)^1WJQ+st77Zj6oMIRw>0dJfP$E*~O_9AC-SCoDT$Y3CWSdC{c zJHrdqGSLjTmGD7er*v(V&yoxEerWeAaBePL&uP?LTp%kIpQ|Go#ZyB9K_$i2*iG1N zYH1!5Ro((i6+pE6GD=@y5!SUY11<;NTyCtcq(6Hy=y5?rt94utm1_5uTCDVh(7SKE zMf={9p`#=7Y2h~>fwpuq_U1D3K zNrRPjE^|xs!BWZ3 zT8Md`Js#}=3ok+7cIWau_Fg9$9}es=EzJEl!9LJ&Udl#JyUFr831@K2x#+%FjoNB9^>{em#%bRiYX9G zw_>sVv~b=$|9~-3LxL3{3k0}AKz(n9*}q-9`VW}m)Iy7JrJbK2F%WC8Z^iO~h{vF@ zy>M+ErZ|;ICETCe8^t-(e23L4Fi9Zs{xF#}wRWcuBMDdPyuHV`-+*zg+GuorA!<9N z)%P&PvB9ldw;teIMF?Rt#_U(GUQN^}a8+SQwk8}N*$4&BSc^UjrZ^TrvbB+94Sdx& z=S_TX!i15qa0}a;OGE@XLI|JNw#!$3f)j@F_SQR`viso%h6U7LG{N z3pj8>fkY~*%A+oo(fvYPq4@gsYo;-e5pK?y`2gB(lyy;OYw#Xlcd9EVlQ3i8V*9UX z*=RSQ%Qkx>QY09-UUJm1*pUgd^z#Kcc94ox_bY%k7IloZ2kY zG-nn8;seC@dM8%ke)jCy``fo~*YMvO60M8YtcBK14s$H&*oKS<2uH4zVaBMX$}#4$ zJ$|{p@WZLIYEdxZSmIcc9Q(q}va%8^7lOCUfCp|`fKMTdts@&z|IZooWm;}T78Ijx zV1FE)eqhqD7(GUEb>F;sGql*~ZYyZ@V%OktLB1{xjEoiigW1++O~SP>3tN#3Z``<1 z#0=*VHnw)SV#mz`I8`1_6$A)AoBBp$*pSWm{0Nd#IgW~igkX&WCY;SK*mO92BlW*U?IlLeI zYJ!2%)sbn131Vg@L!WX2$@U~U(MaM6CKbK*<1q$~6LG2NSuDr^bR%`SthDp)0g(VS z!I{^P%QB4}^vVDIuRmW%l=tP1D7u@Qo5TAj3kc&IlOUrt6~S+2;0&pSf?yFQgG04& zmG--WU|ua+hSqxxUCz2(56oW0^C2FSGC6+3*u?i9)4R`%3z&d4B*~;u{q^q`23dWO zq=8JJP-X%@uq-ICJf(Ona=t=VZl`o*Xn7K27xY6s1kyDHD+tb>#M{8h-1EtzKa6)j zu~sf8KPqB3D4PX+1Z|qS?liz4AeMl>p5lx5ckbM=@V$%yrxo5r5%R%;!$o4Ys&=r1 z`l1~z3&;vyY=DICw>qUCn}cI?T}9cdiT~_UTpAD#_0EKA<80PyU;4{1NMHEvZH@Qh zSzwS907P9~oG3y#PU%`J(pOuU{^uz98IM~tiofM{6xpXG9lMy(K7<^=wNzHrtsF=v zDR}YXg^jOuB;X2mh`wN|??OgQ+C}RC6Q`%DI7x7YZ-8^+&>_zew4YW#+T%q{X7VXs=G23L{SRQoTav-h~MD#>(}<+f3kpzWiK0Yf@iEx9bBUUQ3anW zG@H#Xro$wqWbyZhjvmY|he?787UR$??`*a!n9W8{U{)*ZZ?S~ppgN?r$>&rnHbML- zgYmS)MJ+r_F&vJ#e9xA2Y{#lAh^@$AwZT)q^fXwro^WYE$l}oslcF>*QQ(Ym@vWub z{Na=H7j$8)$E(oatT4g?vx;w?A4(b_2`s6O>e{qA@$X5fLk{muQmVitAeuSLfxZnY z$d3a=KCf1*QC|*97?!2uSO%LF9LCzI!ALM%Bb{j8Joo!3orgezF6bjm-P|D8+PQS~ zK8o2@)-%2%kyfq~jayYD@TIL>80;rwJYe>IgEHaN6Q1w+HMwlyx`Q}Sj;j=e3;L20 z=b`6G+z*z;1ZIUh%}q7}SvVUe1k!mbDV9zb8Ob>j5cxtSKK_M9ToCIe<2RGd#p<(O zay!1k=WG}SmfSF*Of1kt4BYaJF_(kH~D-dU;qJu18CU9CS zgd;%4%+rSd0s5AM6=Q`WLO3~Lz{Eq2zXEM6txnQ~R-*AR5vBheNgPP_^^yGu;))x}gP^`m3|uNFKMugvtU_n8IN_m|-u0kk_hPEd-{#uDRxb}Jh! z+>`rE>~U-EACdJE(bIOKognLr)R80va<39$3bUxUVJzJ0U#?gp#5mKbUbFWPla_W6 z&V+4O6m7WMmlA8KayvF8(Usb=XkC@GI(!u{si>f(bT&3Nv~)Rh=ld%k@n#!?9R_)T zgW_=LbubpPRp&!-(E>fVV+EzO5H9k}LXq5X9}H4|>$`Hpr4)|nY|5k!t0=~<#p(rX zooo$M=x+xb4yHLDuAwz;!ojeSY%5Vsj)dooT9%FXQIlQ2YP%6A z;xin(Iug@{6bPZcaW}gTmlgEbS?DHo;O5!XH-KstAUkMHlhtvctI*XkmbwH8_z0lXw3?Nf&WxT#i zJ4|4TRFLq@B}GBrIB?vNAe?EMM3^SzK$dJJ3VN7!(myHi{|J@vEN>h*?np2sd~&A- zM}&}U)87}-KvH;4ZX7u7P?{tnU6`31+RcP+lzt}QCzwJD!9Fy>nuC`BuAPt_7o;W) zB-k2;L0M4|CJToMJ$0j}4_a9&ro)dyEv*P+Ahc?YaiotaPBye+PjS4EONR>uz{T)knP3aD2!BP0tQbAbL-7XIv9YK_&pN;qQy~5e3Ubkw TJlDmW00000NkvXXu0mjfI-Xg$ literal 0 HcmV?d00001 diff --git a/public/images/website_types/shopit/shopit_logo.png:Zone.Identifier b/public/images/website_types/shopit/shopit_logo.png:Zone.Identifier new file mode 100644 index 0000000000000000000000000000000000000000..d6c1ec682968c796b9f5e9e080cc6f674b57c766 GIT binary patch literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2xassertSame(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')), + file_get_contents(public_path('images/website_types/onticket/onticket_favicon.svg')), Storage::disk('s3')->get($attachment->path), ); } diff --git a/tests/Feature/Seeders/WebsiteTypeSeederTest.php b/tests/Feature/Seeders/WebsiteTypeSeederTest.php index 2678ca3c..d99daf52 100644 --- a/tests/Feature/Seeders/WebsiteTypeSeederTest.php +++ b/tests/Feature/Seeders/WebsiteTypeSeederTest.php @@ -23,7 +23,7 @@ class WebsiteTypeSeederTest extends TestCase $this->assertSame(2, AdminWebsiteType::query()->count()); $this->assertSame(3, StorefrontWebsiteType::query()->count()); - $this->assertSame(5, Attachment::query()->count()); + $this->assertSame(6, Attachment::query()->count()); $expectedPresentation = [ 'primary_color' => '#FF7006', @@ -45,12 +45,15 @@ class WebsiteTypeSeederTest extends TestCase $this->assertSame('ShopIt', $shopIt->nombre); $this->assertSame('localhost', $shopIt->dominio); $this->assertSame('scanner.localhost', $shopIt->scanner_domain); - $this->assertSame($expectedPresentation, $shopIt->only(array_keys($expectedPresentation))); - $this->assertSame('onticket_logo.png', $shopIt->siteLogo->filename); + $this->assertSame( + [...$expectedPresentation, 'primary_color' => '#2FD3AC'], + $shopIt->only(array_keys($expectedPresentation)), + ); + $this->assertSame('shopit_logo.png', $shopIt->siteLogo->filename); Storage::disk('s3')->assertExists($shopIt->siteLogo->path); - $this->assertSame('onticket_footer_logo.png', $shopIt->footerLogo->filename); + $this->assertSame('shopit_footer_logo.png', $shopIt->footerLogo->filename); Storage::disk('s3')->assertExists($shopIt->footerLogo->path); - $this->assertSame('onticket_favicon.svg', $shopIt->favicon->filename); + $this->assertSame('shopit-favicon.svg', $shopIt->favicon->filename); Storage::disk('s3')->assertExists($shopIt->favicon->path); $shopItStorefront = StorefrontWebsiteType::query()->where('codigo', 'shopit')->with('extras')->sole(); $this->assertSame('standard', $shopItStorefront->header_type); @@ -84,7 +87,7 @@ 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->assertNotSame($shopIt->favicon_id, $onTicket->favicon_id); $this->assertSame('onticket_favicon.svg', $onTicket->favicon->filename); $onTicketStorefront = StorefrontWebsiteType::query()->where('codigo', 'onticket')->with('extras')->sole(); $this->assertSame('standard', $onTicketStorefront->header_type); From 85cd1ad04f82e074d7eb6f1788b843ff098ed02c Mon Sep 17 00:00:00 2001 From: nahu Date: Mon, 28 Sep 2026 19:13:36 +0000 Subject: [PATCH 02/18] feat(events): associate purchases and integrations with events --- .../Commerce/Purchase/Models/Purchase.php | 13 ++++- .../Purchase/Resources/PurchaseResource.php | 5 +- .../Checkout/PurchaseResponseLoader.php | 1 + .../Checkout/StartCheckoutService.php | 4 ++ app/Domains/Core/Client/Models/Client.php | 9 ++- app/Domains/Ticketing/Event/Models/Event.php | 16 +++++- ...6_09_28_000000_add_client_id_to_events.php | 26 +++++++++ ...6_09_28_000100_add_event_id_to_compras.php | 44 ++++++++++++++ ...sign_arrufo_event_to_pyme_rural_client.php | 42 ++++++++++++++ .../seeders/FiestaTradicionArrufoSeeder.php | 8 +++ .../Migrations/AddClientIdToEventsTest.php | 57 +++++++++++++++++++ .../Migrations/AddEventIdToPurchasesTest.php | 44 ++++++++++++++ ...AssignArrufoEventToPymeRuralClientTest.php | 50 ++++++++++++++++ 13 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2026_09_28_000000_add_client_id_to_events.php create mode 100644 database/migrations/2026_09_28_000100_add_event_id_to_compras.php create mode 100644 database/migrations/2026_09_28_000200_assign_arrufo_event_to_pyme_rural_client.php create mode 100644 tests/Feature/Migrations/AddClientIdToEventsTest.php create mode 100644 tests/Feature/Migrations/AddEventIdToPurchasesTest.php create mode 100644 tests/Feature/Migrations/AssignArrufoEventToPymeRuralClientTest.php diff --git a/app/Domains/Commerce/Purchase/Models/Purchase.php b/app/Domains/Commerce/Purchase/Models/Purchase.php index 5c75fa7f..68fec2f3 100644 --- a/app/Domains/Commerce/Purchase/Models/Purchase.php +++ b/app/Domains/Commerce/Purchase/Models/Purchase.php @@ -2,13 +2,14 @@ namespace App\Domains\Commerce\Purchase\Models; -use App\Domains\Core\Auth\Models\User; use App\Domains\Commerce\Cart\Models\Cart; use App\Domains\Commerce\Catalog\Models\StockReservation; -use App\Shared\Logging\Models\Concerns\LogsValueChanges; use App\Domains\Commerce\Purchase\Events\PurchasePaid; +use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; use App\Domains\Ticketing\Ticket\Models\Ticket; +use App\Shared\Logging\Models\Concerns\LogsValueChanges; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -21,6 +22,7 @@ use Illuminate\Support\Facades\DB; #[Fillable([ 'cart_id', 'stock_reservation_id', + 'event_id', 'tenant_codigo', 'user_id', 'status', @@ -149,6 +151,7 @@ class Purchase extends Model return [ 'cart_id' => 'integer', 'stock_reservation_id' => 'integer', + 'event_id' => 'integer', 'user_id' => 'integer', 'total' => 'decimal:2', ]; @@ -162,6 +165,12 @@ class Purchase extends Model return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); } + /** @return BelongsTo */ + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + /** * @return BelongsTo */ diff --git a/app/Domains/Commerce/Purchase/Resources/PurchaseResource.php b/app/Domains/Commerce/Purchase/Resources/PurchaseResource.php index 2e98a2e8..d5624cf5 100644 --- a/app/Domains/Commerce/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Commerce/Purchase/Resources/PurchaseResource.php @@ -36,11 +36,11 @@ class PurchaseResource extends JsonResource === StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT; /** @var Event|null $checkoutEvent */ $checkoutEvent = $usesEventSummary - ? $items + ? ($this->event ?? $items ->map(fn (PurchaseItem $item) => $item->sourceCatalogItem?->event) ->filter() ->unique('id') - ->first() + ->first()) : null; $subtotal = $items->isNotEmpty() @@ -62,6 +62,7 @@ class PurchaseResource extends JsonResource return [ 'id' => $this->id, 'cart_id' => $this->cart_id, + 'event_id' => $this->event_id, 'tenant_codigo' => $this->tenant_codigo, 'user_id' => $this->user_id, 'created_at' => $this->created_at, diff --git a/app/Domains/Commerce/Purchase/Services/Checkout/PurchaseResponseLoader.php b/app/Domains/Commerce/Purchase/Services/Checkout/PurchaseResponseLoader.php index ed82d2d6..88dfb5f3 100644 --- a/app/Domains/Commerce/Purchase/Services/Checkout/PurchaseResponseLoader.php +++ b/app/Domains/Commerce/Purchase/Services/Checkout/PurchaseResponseLoader.php @@ -14,6 +14,7 @@ class PurchaseResponseLoader === StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT; $relations = [ 'tenant', + 'event.attachment', 'items.sourceCatalogItem.event.attachment', 'stockReservation', ]; diff --git a/app/Domains/Commerce/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Commerce/Purchase/Services/Checkout/StartCheckoutService.php index e2a1bf14..e21ab889 100644 --- a/app/Domains/Commerce/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Commerce/Purchase/Services/Checkout/StartCheckoutService.php @@ -206,6 +206,7 @@ class StartCheckoutService fn (array $line): float => $line['selection']->getPrice() * $line['quantity'], ), $cart->getKey(), + $cartItems->first()?->catalogItem?->event_id, ); $cart->update(['current_purchase_id' => $purchase->getKey()]); $this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration()); @@ -273,6 +274,7 @@ class StartCheckoutService $purchaseData, $cart->getTotalAmount(), $cart->getKey(), + $cartItems->first()?->catalogItem?->event_id, ); $cart->update(['current_purchase_id' => $purchase->getKey()]); $this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration()); @@ -410,10 +412,12 @@ class StartCheckoutService array $purchaseData, float $total, ?int $cartId, + ?int $eventId, ): Purchase { return Purchase::query()->create([ ...$purchaseData, 'cart_id' => $cartId, + 'event_id' => $eventId, 'tenant_codigo' => $tenant->codigo, 'user_id' => $userId, 'status' => Purchase::STATUS_CREATED, diff --git a/app/Domains/Core/Client/Models/Client.php b/app/Domains/Core/Client/Models/Client.php index f52054ff..e7381320 100644 --- a/app/Domains/Core/Client/Models/Client.php +++ b/app/Domains/Core/Client/Models/Client.php @@ -2,8 +2,9 @@ namespace App\Domains\Core\Client\Models; -use App\Shared\Integration\Models\ClientIntegration; use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; +use App\Shared\Integration\Models\ClientIntegration; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -22,6 +23,12 @@ class Client extends Model return $this->hasMany(Tenant::class); } + /** @return HasMany */ + public function events(): HasMany + { + return $this->hasMany(Event::class); + } + /** @return HasMany */ public function integrations(): HasMany { diff --git a/app/Domains/Ticketing/Event/Models/Event.php b/app/Domains/Ticketing/Event/Models/Event.php index d27905f1..ca3e042d 100644 --- a/app/Domains/Ticketing/Event/Models/Event.php +++ b/app/Domains/Ticketing/Event/Models/Event.php @@ -3,6 +3,7 @@ namespace App\Domains\Ticketing\Event\Models; use App\Domains\Commerce\Catalog\Models\CatalogItem; +use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Tenant\Models\SocialMedia; use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Ticket\Models\Ticket; @@ -14,14 +15,14 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['tenant_code', 'slug', 'event_category_id', 'title', 'subtitle', 'description', 'location', 'exact_location', 'date_text', 'published_at', 'attachment_id'])] +#[Fillable(['client_id', 'tenant_code', 'slug', 'event_category_id', 'title', 'subtitle', 'description', 'location', 'exact_location', 'date_text', 'published_at', 'attachment_id'])] class Event extends Model { use HasFactory; protected function casts(): array { - return ['published_at' => 'datetime', 'exact_location' => 'array']; + return ['client_id' => 'integer', 'published_at' => 'datetime', 'exact_location' => 'array']; } /** @return BelongsTo */ @@ -30,6 +31,17 @@ class Event extends Model return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); } + /** @return BelongsTo */ + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + public function effectiveClient(): Client + { + return $this->client ?? $this->tenant->client; + } + /** @return BelongsTo */ public function eventCategory(): BelongsTo { diff --git a/database/migrations/2026_09_28_000000_add_client_id_to_events.php b/database/migrations/2026_09_28_000000_add_client_id_to_events.php new file mode 100644 index 00000000..039f6804 --- /dev/null +++ b/database/migrations/2026_09_28_000000_add_client_id_to_events.php @@ -0,0 +1,26 @@ +foreignId('client_id') + ->nullable() + ->after('id') + ->constrained('clients') + ->restrictOnDelete(); + }); + } + + public function down(): void + { + Schema::table('events', function (Blueprint $table): void { + $table->dropConstrainedForeignId('client_id'); + }); + } +}; diff --git a/database/migrations/2026_09_28_000100_add_event_id_to_compras.php b/database/migrations/2026_09_28_000100_add_event_id_to_compras.php new file mode 100644 index 00000000..0dad3a5b --- /dev/null +++ b/database/migrations/2026_09_28_000100_add_event_id_to_compras.php @@ -0,0 +1,44 @@ +foreignId('event_id') + ->nullable() + ->after('tenant_codigo') + ->constrained('events') + ->nullOnDelete(); + }); + + DB::table('compras') + ->orderBy('id') + ->each(function (object $purchase): void { + $eventIds = DB::table('compra_items') + ->join('catalog_items', 'catalog_items.id', '=', 'compra_items.source_catalog_item_id') + ->where('compra_items.compra_id', $purchase->id) + ->whereNotNull('catalog_items.event_id') + ->distinct() + ->pluck('catalog_items.event_id'); + + if ($eventIds->count() === 1) { + DB::table('compras') + ->where('id', $purchase->id) + ->update(['event_id' => $eventIds->first()]); + } + }); + } + + public function down(): void + { + Schema::table('compras', function (Blueprint $table): void { + $table->dropConstrainedForeignId('event_id'); + }); + } +}; diff --git a/database/migrations/2026_09_28_000200_assign_arrufo_event_to_pyme_rural_client.php b/database/migrations/2026_09_28_000200_assign_arrufo_event_to_pyme_rural_client.php new file mode 100644 index 00000000..601f22e8 --- /dev/null +++ b/database/migrations/2026_09_28_000200_assign_arrufo_event_to_pyme_rural_client.php @@ -0,0 +1,42 @@ +where('code', 'pyme_rural') + ->value('id'); + + if ($clientId === null) { + return; + } + + DB::table('events') + ->where('tenant_code', 'onticket') + ->where('title', self::EVENT_TITLE) + ->update(['client_id' => $clientId]); + } + + public function down(): void + { + $clientId = DB::table('clients') + ->where('code', 'pyme_rural') + ->value('id'); + + if ($clientId === null) { + return; + } + + DB::table('events') + ->where('tenant_code', 'onticket') + ->where('title', self::EVENT_TITLE) + ->where('client_id', $clientId) + ->update(['client_id' => null]); + } +}; diff --git a/database/seeders/FiestaTradicionArrufoSeeder.php b/database/seeders/FiestaTradicionArrufoSeeder.php index 79b0dbbd..69263cb7 100644 --- a/database/seeders/FiestaTradicionArrufoSeeder.php +++ b/database/seeders/FiestaTradicionArrufoSeeder.php @@ -6,6 +6,7 @@ use App\Domains\Commerce\Catalog\Enums\InventoryPolicy; use App\Domains\Commerce\Catalog\Models\Attribute; use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Services\CatalogService; +use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Event\Models\Event; use App\Shared\Attachable\Services\AttachmentService; @@ -35,6 +36,12 @@ class FiestaTradicionArrufoSeeder extends Seeder throw new RuntimeException("Tenant 'onticket' no encontrado."); } + $client = Client::query()->where('code', 'pyme_rural')->first(); + + if ($client === null) { + throw new RuntimeException("Client 'pyme_rural' no encontrado."); + } + Attribute::query()->firstOrCreate( ['tenant_codigo' => $tenant->codigo, 'codigo' => 'event_date'], ['nombre' => 'Fecha', 'type' => FieldType::EventDate, 'is_required' => true], @@ -45,6 +52,7 @@ class FiestaTradicionArrufoSeeder extends Seeder ['published_at' => now()], ); $event->update([ + 'client_id' => $client->id, 'subtitle' => 'Una noche para celebrar nuestras raíces y mantener viva la tradición gaucha.', 'description' => 'La 26.º Fiesta de la Tradición y 4.º Encuentro de Agrupaciones Gauchas reunirá a agrupaciones, artesanos, pilcheros y público en general para compartir una jornada dedicada a nuestras costumbres y cultura.' ."\n\n".'Un encuentro para disfrutar de la tradición, la identidad gaucha y el espíritu de camaradería, en el Predio de Doma del Club Unión Deportiva Arrufó.' diff --git a/tests/Feature/Migrations/AddClientIdToEventsTest.php b/tests/Feature/Migrations/AddClientIdToEventsTest.php new file mode 100644 index 00000000..1fdb6e0d --- /dev/null +++ b/tests/Feature/Migrations/AddClientIdToEventsTest.php @@ -0,0 +1,57 @@ +id(); + $table->string('code'); + $table->string('name'); + }); + Schema::create('tenants', function (Blueprint $table): void { + $table->id(); + $table->foreignId('client_id'); + $table->string('codigo'); + }); + Schema::create('events', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('title'); + $table->timestamps(); + }); + + DB::table('clients')->insert([ + ['id' => 10, 'code' => 'tenant-client', 'name' => 'Tenant Client'], + ['id' => 11, 'code' => 'event-client', 'name' => 'Event Client'], + ]); + DB::table('tenants')->insert(['id' => 20, 'client_id' => 10, 'codigo' => 'tenant']); + DB::table('events')->insert([ + 'id' => 30, + 'tenant_code' => 'tenant', + 'title' => 'Event', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $migration = require database_path('migrations/2026_09_28_000000_add_client_id_to_events.php'); + $migration->up(); + + $event = Event::query()->findOrFail(30); + $this->assertSame(10, $event->effectiveClient()->id); + + $event->update(['client_id' => 11]); + + $this->assertSame(11, $event->fresh()->effectiveClient()->id); + $this->assertTrue(Client::query()->findOrFail(11)->events()->whereKey(30)->exists()); + } +} diff --git a/tests/Feature/Migrations/AddEventIdToPurchasesTest.php b/tests/Feature/Migrations/AddEventIdToPurchasesTest.php new file mode 100644 index 00000000..2c6d24e3 --- /dev/null +++ b/tests/Feature/Migrations/AddEventIdToPurchasesTest.php @@ -0,0 +1,44 @@ + $table->id()); + Schema::create('compras', fn (Blueprint $table) => $table->id()); + Schema::create('catalog_items', function (Blueprint $table): void { + $table->id(); + $table->foreignId('event_id')->nullable(); + }); + Schema::create('compra_items', function (Blueprint $table): void { + $table->id(); + $table->foreignId('compra_id'); + $table->foreignId('source_catalog_item_id'); + }); + + DB::table('events')->insert([['id' => 10], ['id' => 11]]); + DB::table('compras')->insert([['id' => 20], ['id' => 21]]); + DB::table('catalog_items')->insert([ + ['id' => 30, 'event_id' => 10], + ['id' => 31, 'event_id' => 11], + ]); + DB::table('compra_items')->insert([ + ['compra_id' => 20, 'source_catalog_item_id' => 30], + ['compra_id' => 21, 'source_catalog_item_id' => 30], + ['compra_id' => 21, 'source_catalog_item_id' => 31], + ]); + + $migration = require database_path('migrations/2026_09_28_000100_add_event_id_to_compras.php'); + $migration->up(); + + $this->assertDatabaseHas('compras', ['id' => 20, 'event_id' => 10]); + $this->assertDatabaseHas('compras', ['id' => 21, 'event_id' => null]); + } +} diff --git a/tests/Feature/Migrations/AssignArrufoEventToPymeRuralClientTest.php b/tests/Feature/Migrations/AssignArrufoEventToPymeRuralClientTest.php new file mode 100644 index 00000000..91a80fd2 --- /dev/null +++ b/tests/Feature/Migrations/AssignArrufoEventToPymeRuralClientTest.php @@ -0,0 +1,50 @@ +id(); + $table->string('code'); + }); + Schema::create('events', function (Blueprint $table): void { + $table->id(); + $table->foreignId('client_id')->nullable(); + $table->string('tenant_code'); + $table->string('title'); + }); + + DB::table('clients')->insert([ + ['id' => 10, 'code' => 'pyme_rural'], + ['id' => 11, 'code' => 'onticket'], + ]); + DB::table('events')->insert([ + [ + 'id' => 20, + 'client_id' => null, + 'tenant_code' => 'onticket', + 'title' => '26.º Fiesta de la Tradición y 4.º Encuentro de Agrupaciones Gauchas', + ], + [ + 'id' => 21, + 'client_id' => 11, + 'tenant_code' => 'onticket', + 'title' => 'Otro evento', + ], + ]); + + $migration = require database_path('migrations/2026_09_28_000200_assign_arrufo_event_to_pyme_rural_client.php'); + $migration->up(); + + $this->assertDatabaseHas('events', ['id' => 20, 'client_id' => 10]); + $this->assertDatabaseHas('events', ['id' => 21, 'client_id' => 11]); + } +} From a4bd9263a2dab71cad8186b76559b42f8392a3c8 Mon Sep 17 00:00:00 2001 From: nahu Date: Mon, 28 Sep 2026 19:14:23 +0000 Subject: [PATCH 03/18] feat(integrations): resolve payment context from events --- .../Controllers/PurchaseController.php | 11 ++- .../Services/BaseIntegrationService.php | 16 +++- .../Integration/Services/MailService.php | 38 +++++---- .../Services/TelepagosIntegrationService.php | 2 +- .../Services/TelepagosWebhookService.php | 39 ++++++++- .../Services/NotificationMailService.php | 28 ++++--- .../Integration/TelepagosWebhookTest.php | 81 ++++++++++++++++++- 7 files changed, 173 insertions(+), 42 deletions(-) diff --git a/app/Domains/Commerce/Purchase/Controllers/PurchaseController.php b/app/Domains/Commerce/Purchase/Controllers/PurchaseController.php index f2036131..19a94804 100644 --- a/app/Domains/Commerce/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Commerce/Purchase/Controllers/PurchaseController.php @@ -2,7 +2,6 @@ namespace App\Domains\Commerce\Purchase\Controllers; -use App\Shared\Integration\Services\TelepagosIntegrationService; use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Requests\PaymentIntentRequest; use App\Domains\Commerce\Purchase\Requests\StartCheckoutRequest; @@ -13,6 +12,7 @@ use App\Domains\Commerce\Purchase\Services\CheckoutService; use App\Domains\Commerce\Purchase\Services\PurchaseStateGuard; use App\Domains\Core\Tenant\Models\Tenant; use App\Http\Controllers\Controller; +use App\Shared\Integration\Services\TelepagosIntegrationService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -151,11 +151,14 @@ class PurchaseController extends Controller } $compra->refresh(); + $compra->loadMissing('event'); $totalAmount = (float) $compra->total; if ($method === 'transfer') { $telepagosService = new TelepagosIntegrationService; - $telepagosService->forTenant($tenant->codigo); + $compra->event + ? $telepagosService->forEvent($compra->event) + : $telepagosService->forTenant($tenant->codigo); try { $accountInfo = $telepagosService->getAccountInfo(); @@ -191,7 +194,9 @@ class PurchaseController extends Controller if ($method === 'qr') { $telepagosService = new TelepagosIntegrationService; - $telepagosService->forTenant($tenant->codigo); + $compra->event + ? $telepagosService->forEvent($compra->event) + : $telepagosService->forTenant($tenant->codigo); try { Log::channel('telepagos')->info('Generating Telepagos QR.', [ diff --git a/app/Shared/Integration/Services/BaseIntegrationService.php b/app/Shared/Integration/Services/BaseIntegrationService.php index a0244730..09f16b1b 100644 --- a/app/Shared/Integration/Services/BaseIntegrationService.php +++ b/app/Shared/Integration/Services/BaseIntegrationService.php @@ -3,11 +3,12 @@ namespace App\Shared\Integration\Services; use App\Domains\Core\Client\Models\Client; +use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; +use App\Shared\Integration\Models\AdminWebsiteTypeIntegration; use App\Shared\Integration\Models\ClientIntegration; use App\Shared\Integration\Models\Integration; use App\Shared\Integration\Models\IntegrationInstance; -use App\Shared\Integration\Models\AdminWebsiteTypeIntegration; -use App\Domains\Core\Tenant\Models\Tenant; use Exception; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; @@ -86,6 +87,17 @@ abstract class BaseIntegrationService return $this; } + public function forEvent(Event $event): self + { + $event->loadMissing(['client', 'tenant.client']); + $this->tenant = $event->tenant; + $this->tenantCode = $event->tenant_code; + $this->clientContext = $event->effectiveClient(); + $this->loadIntegration(); + + return $this; + } + /** * Load the integration definition and its effective instance configuration. * diff --git a/app/Shared/Integration/Services/MailService.php b/app/Shared/Integration/Services/MailService.php index 71682e0a..cadffb0b 100644 --- a/app/Shared/Integration/Services/MailService.php +++ b/app/Shared/Integration/Services/MailService.php @@ -3,8 +3,9 @@ namespace App\Shared\Integration\Services; use App\Domains\Core\Client\Models\Client; -use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\AdminWebsiteType; +use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; use Exception; use Illuminate\Contracts\Mail\Factory as MailFactory; use Illuminate\Contracts\Mail\Mailer; @@ -39,14 +40,7 @@ class MailService extends BaseIntegrationService public function forTenant(string $tenantCode): self { parent::forTenant($tenantCode); - - if ($this->integrationInstance) { - $this->mailer = $this->resolveMailer(); - $this->usesInstanceMailer = true; - } else { - $this->mailer = $this->mailFactory->mailer(); - $this->usesInstanceMailer = false; - } + $this->configureMailer(); return $this; } @@ -55,16 +49,30 @@ class MailService extends BaseIntegrationService { parent::forClient($client); $this->tenant = $this->clientContext?->tenants()->first(); + $this->configureMailer(); + return $this; + } + + public function forEvent(Event $event): self + { + parent::forEvent($event); + $this->configureMailer(); + + return $this; + } + + private function configureMailer(): void + { if ($this->integrationInstance) { $this->mailer = $this->resolveMailer(); $this->usesInstanceMailer = true; - } else { - $this->mailer = $this->mailFactory->mailer(); - $this->usesInstanceMailer = false; + + return; } - return $this; + $this->mailer = $this->mailFactory->mailer(); + $this->usesInstanceMailer = false; } public function getHeaders(): array @@ -83,7 +91,7 @@ class MailService extends BaseIntegrationService array $attachments = [], ): void { if (! $this->mailer || ! $this->tenant) { - throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.'); + throw new Exception('MailService no está configurado. Llamá a forTenant(), forClient() o forEvent() primero.'); } $brand ??= $this->tenant; @@ -160,7 +168,7 @@ class MailService extends BaseIntegrationService public function onSetup(): void { if (! $this->mailer || ! $this->clientContext) { - throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.'); + throw new Exception('MailService no está configurado. Llamá a forTenant(), forClient() o forEvent() primero.'); } $recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS'); diff --git a/app/Shared/Integration/Services/TelepagosIntegrationService.php b/app/Shared/Integration/Services/TelepagosIntegrationService.php index 83ac8101..424d02b5 100644 --- a/app/Shared/Integration/Services/TelepagosIntegrationService.php +++ b/app/Shared/Integration/Services/TelepagosIntegrationService.php @@ -46,7 +46,7 @@ class TelepagosIntegrationService extends BaseIntegrationService public function getToken(): string { if (! $this->integrationInstance) { - throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.'); + throw new Exception('Client integration is not loaded. Call forTenant(), forClient(), or forEvent() first.'); } $cacheKey = $this->integrationInstance->tokenCacheKey(); diff --git a/app/Shared/Integration/Services/TelepagosWebhookService.php b/app/Shared/Integration/Services/TelepagosWebhookService.php index 8c120cb7..20699e8a 100644 --- a/app/Shared/Integration/Services/TelepagosWebhookService.php +++ b/app/Shared/Integration/Services/TelepagosWebhookService.php @@ -2,12 +2,12 @@ namespace App\Shared\Integration\Services; -use App\Domains\Core\Client\Models\Client; use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Models\TelepagosPayment; use App\Domains\Commerce\Purchase\Models\TelepagosQr; use App\Domains\Commerce\Purchase\Services\CheckoutService; use App\Domains\Commerce\Purchase\Services\DniDistanceService; +use App\Domains\Core\Client\Models\Client; use Exception; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -75,9 +75,30 @@ class TelepagosWebhookService $dni = substr($cuit, 2, -1); - $tenantCodes = $client->tenants()->pluck('codigo'); $eligiblePurchases = Purchase::query() - ->whereIn('tenant_codigo', $tenantCodes) + ->where(function (Builder $purchases) use ($client): void { + $purchases + ->whereHas('event', function (Builder $events) use ($client): void { + $events + ->where('client_id', $client->id) + ->orWhere(function (Builder $fallback) use ($client): void { + $fallback + ->whereNull('client_id') + ->whereHas( + 'tenant', + fn (Builder $tenants): Builder => $tenants->where('client_id', $client->id), + ); + }); + }) + ->orWhere(function (Builder $legacy) use ($client): void { + $legacy + ->whereNull('event_id') + ->whereHas( + 'tenant', + fn (Builder $tenants): Builder => $tenants->where('client_id', $client->id), + ); + }); + }) ->whereIn('status', [ Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, @@ -170,7 +191,7 @@ class TelepagosWebhookService return; } - if (! $client->tenants()->where('codigo', $compra->tenant_codigo)->exists()) { + if (! $this->purchaseBelongsToClient($compra, $client)) { Log::channel('telepagos')->warning('Telepagos webhook: Purchase does not belong to client.', [ 'client_code' => $client->code, 'cashin_id' => $cashinId, @@ -244,6 +265,16 @@ class TelepagosWebhookService return number_format((float) $amount, 2, '.', ''); } + private function purchaseBelongsToClient(Purchase $purchase, Client $client): bool + { + $purchase->loadMissing(['event.client', 'event.tenant.client', 'tenant.client']); + + $effectiveClient = $purchase->event?->effectiveClient() + ?? $purchase->tenant?->client; + + return $effectiveClient?->is($client) ?? false; + } + /** * @param Builder $eligiblePurchases * @return Collection diff --git a/app/Shared/Notification/Services/NotificationMailService.php b/app/Shared/Notification/Services/NotificationMailService.php index 707436d3..00866957 100644 --- a/app/Shared/Notification/Services/NotificationMailService.php +++ b/app/Shared/Notification/Services/NotificationMailService.php @@ -2,16 +2,16 @@ namespace App\Shared\Notification\Services; +use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Core\Auth\Models\ResetPasswordAttempt; use App\Domains\Core\Auth\Models\User; -use App\Shared\Integration\Services\MailService; -use App\Shared\Notification\Events\PasswordResetRequested; -use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Services\TicketPdfService; use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver; +use App\Shared\Integration\Services\MailService; +use App\Shared\Notification\Events\PasswordResetRequested; use Closure; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; @@ -158,7 +158,7 @@ class NotificationMailService $context = ['purchase_id' => $purchaseId]; $purchase = Purchase::query() - ->with(['tenant', 'user', 'items']) + ->with(['event', 'tenant', 'user', 'items']) ->find($purchaseId); if ($purchase === null) { @@ -197,8 +197,7 @@ class NotificationMailService 'mime' => 'application/pdf', ]]; - $this->mailService - ->forTenant($purchase->tenant_codigo) + $this->mailForPurchase($purchase) ->send( $recipient, "Compra confirmada - Compra #{$purchase->getKey()}", @@ -286,7 +285,6 @@ class NotificationMailService $context, $recipient, function () use ( - $tenantCode, $purchase, $recipient, $previousDate, @@ -295,8 +293,7 @@ class NotificationMailService ): array { $brand = $purchase->tenant; - $this->mailService - ->forTenant($tenantCode) + $this->mailForPurchase($purchase) ->send( $recipient, "Tu evento fue reprogramado - N° de Orden #{$purchase->getKey()}", @@ -387,7 +384,6 @@ class NotificationMailService $context, $recipient, function () use ( - $tenantCode, $purchase, $recipient, $date, @@ -396,8 +392,7 @@ class NotificationMailService ): array { $brand = $purchase->tenant; - $this->mailService - ->forTenant($tenantCode) + $this->mailForPurchase($purchase) ->send( $recipient, "Una fecha de tu evento fue suspendida - N° de Orden #{$purchase->getKey()}", @@ -421,10 +416,17 @@ class NotificationMailService { return Purchase::query() ->where('tenant_codigo', $tenantCode) - ->with(['tenant', 'user']) + ->with(['event', 'tenant', 'user']) ->find($purchaseId); } + private function mailForPurchase(Purchase $purchase): MailService + { + return $purchase->event + ? $this->mailService->forEvent($purchase->event) + : $this->mailService->forTenant($purchase->tenant_codigo); + } + private function recipientFor(Purchase $purchase): string { return (string) ($purchase->email ?: $purchase->user?->email); diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index 17518dce..4c823273 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -2,20 +2,22 @@ namespace Tests\Feature\Integration; -use App\Shared\Attachable\Enums\AttachmentType; -use App\Shared\Attachable\Models\Attachment; -use App\Domains\Core\Auth\Models\User; use App\Domains\Commerce\Cart\Models\Cart; use App\Domains\Commerce\Catalog\Enums\InventoryPolicy; use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\Category; use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Commerce\Catalog\Models\Variant; -use App\Shared\Integration\Models\Integration; use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Models\TelepagosPayment; use App\Domains\Commerce\Purchase\Services\CheckoutService; +use App\Domains\Core\Auth\Models\User; +use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; +use App\Shared\Attachable\Enums\AttachmentType; +use App\Shared\Attachable\Models\Attachment; +use App\Shared\Integration\Models\Integration; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -227,6 +229,77 @@ class TelepagosWebhookTest extends TestCase ]); } + public function test_transfer_webhook_matches_the_client_configured_on_the_event(): void + { + $tenant = $this->createTenant('multi-event', 'Multi Event', 'multi-event.com.ar'); + $eventClient = Client::query()->create([ + 'code' => 'event-client', + 'name' => 'Event Client', + ]); + Integration::query()->create([ + 'integration_code' => 'telepagos_homo', + 'name' => 'Telepagos', + 'url' => 'https://api.telepagos.com.ar', + 'integration_data_schema' => [ + 'username' => 'required|string', + 'password' => 'required|string', + ], + ]); + $this->createClientIntegration([ + 'client_id' => $eventClient->id, + 'integration_code' => 'telepagos_homo', + 'integration_data' => ['username' => 'event-user', 'password' => 'event-password'], + ]); + $event = Event::query()->create([ + 'client_id' => $eventClient->id, + 'tenant_code' => $tenant->codigo, + 'title' => 'Event with its own client', + ]); + $tenant->update(['active_event_id' => $event->id]); + + $user = User::factory()->create(); + $variant = $this->createVariantForTenant($tenant->codigo, 10, '50.00'); + $purchase = $this->createPendingTransferPurchase( + $tenant, + $user->id, + $variant->id, + 1, + '12345678', + ); + + Http::fake([ + 'https://api.telepagos.com.ar/v2/auth/token' => Http::response([ + 'status' => 'ok', + 'token' => 'event-client-token', + 'expires_at' => now()->addHour()->toIso8601String(), + ]), + 'https://api.telepagos.com.ar/v2/payment/cashin/event-client-payment' => Http::response([ + 'status' => 'ok', + 'data' => [ + 'amount' => 50, + 'operation_id' => 1, + 'transaction_id' => 'tx-event-client', + 'buyer' => ['cuit' => '20123456789'], + ], + ]), + ]); + + $this->postJson('/api/webhooks/telepagos/event-client', [ + 'id' => 'event-client-payment', + ])->assertOk()->assertJsonPath('status', 'success'); + + $this->assertSame($event->id, $purchase->event_id); + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'event_id' => $event->id, + 'status' => Purchase::STATUS_PAID, + ]); + $this->assertDatabaseHas('telepagos_payments', [ + 'compra_id' => $purchase->id, + 'transaction_id' => 'tx-event-client', + ]); + } + public function test_transfer_webhook_buys_unlimited_inventory_without_reducing_real_stock(): void { $tenant = $this->createTenant('unlimited', 'Unlimited', 'unlimited.com.ar'); From 0fe38df019e162902c5ae001cd257f9ea3144b51 Mon Sep 17 00:00:00 2001 From: Nahuel Date: Tue, 29 Sep 2026 11:32:38 +0000 Subject: [PATCH 04/18] Remove Zone.Identifier metadata files --- .../shopit/shopit_footer_logo.png:Zone.Identifier | Bin 25 -> 0 bytes .../shopit/shopit_logo.png:Zone.Identifier | Bin 25 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 public/images/website_types/shopit/shopit_footer_logo.png:Zone.Identifier delete mode 100644 public/images/website_types/shopit/shopit_logo.png:Zone.Identifier diff --git a/public/images/website_types/shopit/shopit_footer_logo.png:Zone.Identifier b/public/images/website_types/shopit/shopit_footer_logo.png:Zone.Identifier deleted file mode 100644 index d6c1ec682968c796b9f5e9e080cc6f674b57c766..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2xdl#JyUFr831@K2x Date: Tue, 29 Sep 2026 09:04:50 -0300 Subject: [PATCH 05/18] feat(menus): separate shopit and onticket admin menus --- app/Domains/Ticketing/Desfile/routes/api.php | 14 +- .../FiestaFutbolInfantil/routes/api.php | 34 +-- .../Ticketing/Ticket/documentacion/README.md | 2 +- .../Ticketing/Ticket/routes/adminapp.php | 12 +- app/Shared/Forms/routes/adminapp.php | 4 +- ...eparate_adminapp_menus_by_website_type.php | 236 ++++++++++++++++++ database/seeders/MenuSeeder.php | 111 ++++++-- .../Feature/Auth/ScannerMeControllerTest.php | 2 +- tests/Feature/Desfile/EntryControllerTest.php | 2 +- .../AccommodationControllerTest.php | 2 +- .../EntryControllerTest.php | 10 +- .../FoodControllerTest.php | 2 +- .../MerchandiseControllerTest.php | 2 +- ...fileEntryReservationFormControllerTest.php | 2 +- ...AdminAppTicketFilterFormControllerTest.php | 2 +- ...SeparateAdminAppMenusByWebsiteTypeTest.php | 201 +++++++++++++++ .../DesfilePuraTendenciaSeederTest.php | 6 +- tests/Feature/Seeders/MenuSeederTest.php | 104 ++++++-- .../Ticket/AdminAppTicketControllerTest.php | 2 +- 19 files changed, 657 insertions(+), 93 deletions(-) create mode 100644 database/migrations/2026_09_29_000000_separate_adminapp_menus_by_website_type.php create mode 100644 tests/Feature/Migrations/SeparateAdminAppMenusByWebsiteTypeTest.php diff --git a/app/Domains/Ticketing/Desfile/routes/api.php b/app/Domains/Ticketing/Desfile/routes/api.php index f588ecdf..fdf83b81 100644 --- a/app/Domains/Ticketing/Desfile/routes/api.php +++ b/app/Domains/Ticketing/Desfile/routes/api.php @@ -5,28 +5,28 @@ use App\Domains\Ticketing\Desfile\Controllers\EntryReservationController; use Illuminate\Support\Facades\Route; Route::get('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'index']) - ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) + ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas']) ->name('adminapp.desfile.entry-reservations.index'); Route::get('v1/adminapp/tenant/desfile/entry-reservations/pdf', [EntryReservationController::class, 'downloadPdf']) - ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) + ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas']) ->name('adminapp.desfile.entry-reservations.pdf'); Route::get('v1/adminapp/tenant/desfile/entry-reservations/excel', [EntryReservationController::class, 'downloadExcel']) - ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) + ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas']) ->name('adminapp.desfile.entry-reservations.excel'); Route::get('v1/adminapp/tenant/desfile/entry-reservations/{reservation}/ticket/pdf', [EntryReservationController::class, 'downloadTicketPdf']) ->whereNumber('reservation') - ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) + ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas']) ->name('adminapp.desfile.entry-reservations.ticket.pdf'); Route::post('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'store']) - ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) + ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas']) ->name('adminapp.desfile.entry-reservations.store'); Route::delete('v1/adminapp/tenant/desfile/entry-reservations/{reservation}', [EntryReservationController::class, 'destroy']) ->whereNumber('reservation') - ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) + ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas']) ->name('adminapp.desfile.entry-reservations.destroy'); Route::prefix('v1/adminapp/tenant/desfile') - ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.entradas']) + ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.entradas']) ->group(function (): void { Route::get('entries', [EntryController::class, 'show']) ->name('adminapp.desfile.entries.show'); diff --git a/app/Domains/Ticketing/FiestaFutbolInfantil/routes/api.php b/app/Domains/Ticketing/FiestaFutbolInfantil/routes/api.php index ff248d9c..75f1ef9f 100644 --- a/app/Domains/Ticketing/FiestaFutbolInfantil/routes/api.php +++ b/app/Domains/Ticketing/FiestaFutbolInfantil/routes/api.php @@ -19,48 +19,48 @@ Route::prefix('v1/adminapp/tenant') ->middleware("tenant.menu:{$menuCode}"); }; - $visibilityRoutes('entries', 'entries', 'adminapp.fiesta-futbol-infantil.entradas'); - $visibilityRoutes('foods', 'foods', 'adminapp.fiesta-futbol-infantil.comida'); - $visibilityRoutes('accommodations', 'accommodations', 'adminapp.fiesta-futbol-infantil.alojamientos'); - $visibilityRoutes('merchandise', 'merchandise', 'adminapp.fiesta-futbol-infantil.merchandising'); + $visibilityRoutes('entries', 'entries', 'onticket.adminapp.fiesta-futbol-infantil.entradas'); + $visibilityRoutes('foods', 'foods', 'onticket.adminapp.fiesta-futbol-infantil.comida'); + $visibilityRoutes('accommodations', 'accommodations', 'onticket.adminapp.fiesta-futbol-infantil.alojamientos'); + $visibilityRoutes('merchandise', 'merchandise', 'onticket.adminapp.fiesta-futbol-infantil.merchandising'); Route::get('entries', [EntryController::class, 'index']) - ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas') + ->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.entradas') ->name('adminapp.fiesta-futbol-infantil.entries.index'); Route::post('entries', [EntryController::class, 'store']) - ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas') + ->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.entradas') ->name('adminapp.fiesta-futbol-infantil.entries.store'); Route::delete('entries/{entry}', [EntryController::class, 'destroy']) - ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas') + ->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.entradas') ->name('adminapp.fiesta-futbol-infantil.entries.destroy'); Route::get('foods', [FoodController::class, 'index']) - ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') + ->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.comida') ->name('adminapp.fiesta-futbol-infantil.foods.index'); Route::post('foods', [FoodController::class, 'store']) - ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') + ->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.comida') ->name('adminapp.fiesta-futbol-infantil.foods.store'); Route::patch('foods/history-stock', [FoodController::class, 'updateHistoricalStock']) - ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') + ->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.comida') ->name('adminapp.fiesta-futbol-infantil.foods.history-stock.update'); Route::delete('foods/{food}', [FoodController::class, 'destroy']) - ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') + ->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.comida') ->name('adminapp.fiesta-futbol-infantil.foods.destroy'); Route::get('accommodations', [AccommodationController::class, 'index']) - ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos') + ->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.alojamientos') ->name('adminapp.fiesta-futbol-infantil.accommodations.index'); Route::post('accommodations', [AccommodationController::class, 'store']) - ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos') + ->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.alojamientos') ->name('adminapp.fiesta-futbol-infantil.accommodations.store'); Route::delete('accommodations/{accommodation}', [AccommodationController::class, 'destroy']) - ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos') + ->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.alojamientos') ->name('adminapp.fiesta-futbol-infantil.accommodations.destroy'); Route::get('merchandise', [MerchandiseController::class, 'index']) - ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising') + ->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.merchandising') ->name('adminapp.fiesta-futbol-infantil.merchandise.index'); Route::post('merchandise', [MerchandiseController::class, 'store']) - ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising') + ->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.merchandising') ->name('adminapp.fiesta-futbol-infantil.merchandise.store'); Route::delete('merchandise/{merchandise}', [MerchandiseController::class, 'destroy']) - ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising') + ->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.merchandising') ->name('adminapp.fiesta-futbol-infantil.merchandise.destroy'); }); diff --git a/app/Domains/Ticketing/Ticket/documentacion/README.md b/app/Domains/Ticketing/Ticket/documentacion/README.md index e460d5c3..fafd61d9 100644 --- a/app/Domains/Ticketing/Ticket/documentacion/README.md +++ b/app/Domains/Ticketing/Ticket/documentacion/README.md @@ -82,7 +82,7 @@ Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`: - `POST /tickets/pdf`. Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el menú -`adminapp.tickets`: +`onticket.adminapp.tickets`: - `GET /tickets`, paginado y con búsqueda opcional mediante `q`. La respuesta incluye `scanned_tickets` y `total_tickets` para el tenant autenticado. diff --git a/app/Domains/Ticketing/Ticket/routes/adminapp.php b/app/Domains/Ticketing/Ticket/routes/adminapp.php index b785612a..17e0329a 100644 --- a/app/Domains/Ticketing/Ticket/routes/adminapp.php +++ b/app/Domains/Ticketing/Ticket/routes/adminapp.php @@ -7,24 +7,24 @@ Route::prefix('v1/adminapp/tenant') ->middleware(['auth:sanctum', 'adminapp.tenant']) ->group(function (): void { Route::get('tickets', [TicketController::class, 'index']) - ->middleware('tenant.menu:adminapp.tickets') + ->middleware('tenant.menu:onticket.adminapp.tickets') ->name('adminapp.tickets.index'); Route::post('tickets/{ticket}/cancel', [TicketController::class, 'cancel']) ->whereNumber('ticket') - ->middleware('tenant.menu:adminapp.tickets') + ->middleware('tenant.menu:onticket.adminapp.tickets') ->name('adminapp.tickets.cancel'); Route::get('tickets/{ticket}/refund', [TicketController::class, 'calculateRefund']) ->whereNumber('ticket') - ->middleware('tenant.menu:adminapp.tickets') + ->middleware('tenant.menu:onticket.adminapp.tickets') ->name('adminapp.tickets.calculate-refund'); Route::post('tickets/{ticket}/refund', [TicketController::class, 'refund']) ->whereNumber('ticket') - ->middleware('tenant.menu:adminapp.tickets') + ->middleware('tenant.menu:onticket.adminapp.tickets') ->name('adminapp.tickets.refund'); Route::get('tickets/pdf', [TicketController::class, 'downloadPdf']) - ->middleware('tenant.menu:adminapp.tickets') + ->middleware('tenant.menu:onticket.adminapp.tickets') ->name('adminapp.tickets.pdf'); Route::get('tickets/excel', [TicketController::class, 'downloadExcel']) - ->middleware('tenant.menu:adminapp.tickets') + ->middleware('tenant.menu:onticket.adminapp.tickets') ->name('adminapp.tickets.excel'); }); diff --git a/app/Shared/Forms/routes/adminapp.php b/app/Shared/Forms/routes/adminapp.php index 6517d639..ed7d070e 100644 --- a/app/Shared/Forms/routes/adminapp.php +++ b/app/Shared/Forms/routes/adminapp.php @@ -18,12 +18,12 @@ Route::prefix('v1/adminapp/forms') Route::get( 'desfile/entry-reservation', DesfileEntryReservationFormController::class - )->middleware('tenant.menu:adminapp.desfile.reservas') + )->middleware('tenant.menu:onticket.adminapp.desfile.reservas') ->name('adminapp.forms.desfile.entry-reservation'); Route::get('sale', SaleFormController::class); Route::get('staff', StaffFormController::class); Route::get('tickets-filter', TicketFilterFormController::class) - ->middleware('tenant.menu:adminapp.tickets') + ->middleware('tenant.menu:onticket.adminapp.tickets') ->name('adminapp.forms.tickets-filter'); Route::get( 'fiesta-futbol-infantil/ticket', diff --git a/database/migrations/2026_09_29_000000_separate_adminapp_menus_by_website_type.php b/database/migrations/2026_09_29_000000_separate_adminapp_menus_by_website_type.php new file mode 100644 index 00000000..8f972599 --- /dev/null +++ b/database/migrations/2026_09_29_000000_separate_adminapp_menus_by_website_type.php @@ -0,0 +1,236 @@ + ['onticket.adminapp.event', 'Eventos', '/admin/event'], + 'adminapp.inicio' => ['onticket.adminapp.inicio', 'Inicio', '/admin/inicio'], + 'adminapp.catalog' => ['onticket.adminapp.catalog', 'Catálogo', '/admin/catalog'], + 'adminapp.combos' => ['onticket.adminapp.combos', 'Combos', '/admin/combos'], + 'adminapp.categories' => ['onticket.adminapp.categories', 'Categorías', '/admin/categories'], + 'adminapp.ventas' => ['onticket.adminapp.ventas', 'Ventas', '/admin/ventas'], + 'adminapp.staff' => ['onticket.adminapp.staff', 'Usuarios', '/admin/staff'], + 'adminapp.tickets' => ['onticket.adminapp.tickets', 'Tickets', '/admin/tickets'], + 'adminapp.fiesta-futbol-infantil.entradas' => [ + 'onticket.adminapp.fiesta-futbol-infantil.entradas', + 'Entradas', + '/admin/entradas', + ], + 'adminapp.fiesta-futbol-infantil.alojamientos' => [ + 'onticket.adminapp.fiesta-futbol-infantil.alojamientos', + 'Alojamientos', + '/admin/alojamientos', + ], + 'adminapp.fiesta-futbol-infantil.merchandising' => [ + 'onticket.adminapp.fiesta-futbol-infantil.merchandising', + 'Merchandising', + '/admin/merchandising', + ], + 'adminapp.fiesta-futbol-infantil.comida' => [ + 'onticket.adminapp.fiesta-futbol-infantil.comida', + 'Comida', + '/admin/comidas', + ], + 'adminapp.desfile.entradas' => [ + 'onticket.adminapp.desfile.entradas', + 'Entradas', + '/admin/desfile/entradas', + ], + 'adminapp.desfile.reservas' => [ + 'onticket.adminapp.desfile.reservas', + 'Reserva de Tickets', + '/admin/desfile/reservas', + ], + ]; + + private const SHOPIT_MENUS = [ + 'shopit.adminapp.marca' => ['Marca', '/admin/marca'], + 'shopit.adminapp.contacto' => ['Contacto', '/admin/contacto'], + 'shopit.adminapp.productos' => ['Productos', '/admin/productos'], + 'shopit.adminapp.categorias' => ['Categorías', '/admin/categorias'], + 'shopit.adminapp.transacciones' => ['Transacciones', '/admin/transacciones'], + 'shopit.adminapp.reportes' => ['Reportes', '/admin/reportes'], + 'shopit.adminapp.usuarios_roles' => ['Usuarios y roles', '/admin/usuarios-roles'], + ]; + + private const LEGACY_SHOPIT_ADMIN_MENUS = [ + 'adminapp.event', + 'adminapp.inicio', + 'adminapp.catalog', + 'adminapp.combos', + 'adminapp.categories', + 'adminapp.ventas', + 'adminapp.staff', + ]; + + public function up(): void + { + DB::transaction(function (): void { + if (! DB::table('menues')->where('code', 'main.adminapp')->exists()) { + return; + } + + foreach (self::ONTICKET_MENUS as $legacyCode => [$code, $label, $route]) { + $this->replaceMenuCode($legacyCode, $code, $label, $route); + $this->assignMenuToAdminRoles($code); + } + + foreach (self::SHOPIT_MENUS as $code => [$label, $route]) { + $this->upsertMenu($code, $label, $route); + $this->assignMenuToAdminRoles($code); + } + + $shopItTenantCodes = DB::table('tenants') + ->where('admin_website_type_code', 'shopit') + ->pluck('codigo'); + + if ($shopItTenantCodes->isEmpty()) { + return; + } + + $adminMenuCodes = DB::table('menues') + ->where('parent_menu_code', 'main.adminapp') + ->pluck('code'); + + DB::table('tenants_menues') + ->whereIn('tenant_code', $shopItTenantCodes) + ->whereIn('menu_code', $adminMenuCodes) + ->delete(); + + foreach ($shopItTenantCodes as $tenantCode) { + $this->assignMenuToTenant($tenantCode, 'main.adminapp'); + + foreach (array_keys(self::SHOPIT_MENUS) as $menuCode) { + $this->assignMenuToTenant($tenantCode, $menuCode); + } + } + }); + } + + public function down(): void + { + DB::transaction(function (): void { + if (! DB::table('menues')->where('code', 'main.adminapp')->exists()) { + return; + } + + $shopItTenantCodes = DB::table('tenants') + ->where('admin_website_type_code', 'shopit') + ->pluck('codigo'); + + DB::table('tenants_menues') + ->whereIn('tenant_code', $shopItTenantCodes) + ->whereIn('menu_code', array_keys(self::SHOPIT_MENUS)) + ->delete(); + DB::table('roles_menues') + ->whereIn('menu_codigo', array_keys(self::SHOPIT_MENUS)) + ->delete(); + DB::table('menues') + ->whereIn('code', array_keys(self::SHOPIT_MENUS)) + ->delete(); + + foreach (self::ONTICKET_MENUS as $legacyCode => [$code, $label, $route]) { + $this->replaceMenuCode($code, $legacyCode, $label, $route); + } + + foreach ($shopItTenantCodes as $tenantCode) { + foreach (self::LEGACY_SHOPIT_ADMIN_MENUS as $menuCode) { + $this->assignMenuToTenant($tenantCode, $menuCode); + } + } + }); + } + + private function replaceMenuCode( + string $sourceCode, + string $targetCode, + string $label, + string $route, + ): void { + $source = DB::table('menues')->where('code', $sourceCode)->first(); + + $this->upsertMenu($targetCode, $label, $route, $source); + + if ($source === null) { + return; + } + + DB::table('tenants_menues') + ->where('menu_code', $sourceCode) + ->get() + ->each(function (object $assignment) use ($targetCode): void { + DB::table('tenants_menues')->updateOrInsert( + [ + 'tenant_code' => $assignment->tenant_code, + 'menu_code' => $targetCode, + ], + [ + 'static_content' => $assignment->static_content, + 'created_at' => $assignment->created_at ?? now(), + 'updated_at' => now(), + ], + ); + }); + + DB::table('roles_menues') + ->where('menu_codigo', $sourceCode) + ->pluck('rol_codigo') + ->each(fn (string $roleCode) => DB::table('roles_menues')->updateOrInsert([ + 'rol_codigo' => $roleCode, + 'menu_codigo' => $targetCode, + ])); + + DB::table('tenants_menues')->where('menu_code', $sourceCode)->delete(); + DB::table('roles_menues')->where('menu_codigo', $sourceCode)->delete(); + DB::table('menues')->where('code', $sourceCode)->delete(); + } + + private function upsertMenu( + string $code, + string $label, + string $route, + ?object $source = null, + ): void { + DB::table('menues')->updateOrInsert( + ['code' => $code], + [ + 'label' => $source->label ?? $label, + 'parent_menu_code' => 'main.adminapp', + 'content_type' => $source->content_type ?? 'dynamic', + 'static_content_schema' => $source->static_content_schema ?? null, + 'route' => $source->route ?? $route, + 'created_at' => $source->created_at ?? now(), + 'updated_at' => now(), + ], + ); + } + + private function assignMenuToAdminRoles(string $menuCode): void + { + DB::table('roles') + ->whereIn('codigo', ['admin', 'adminapp']) + ->pluck('codigo') + ->each(fn (string $roleCode) => DB::table('roles_menues')->updateOrInsert([ + 'rol_codigo' => $roleCode, + 'menu_codigo' => $menuCode, + ])); + } + + private function assignMenuToTenant(string $tenantCode, string $menuCode): void + { + DB::table('tenants_menues')->updateOrInsert( + [ + 'tenant_code' => $tenantCode, + 'menu_code' => $menuCode, + ], + [ + 'static_content' => null, + 'created_at' => now(), + 'updated_at' => now(), + ], + ); + } +}; diff --git a/database/seeders/MenuSeeder.php b/database/seeders/MenuSeeder.php index 25a61dcb..5974e633 100644 --- a/database/seeders/MenuSeeder.php +++ b/database/seeders/MenuSeeder.php @@ -53,89 +53,131 @@ class MenuSeeder extends Seeder 'route' => '/scanner/detail/:id', ], [ - 'code' => 'adminapp.event', + 'code' => 'onticket.adminapp.event', 'label' => 'Eventos', 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/event', ], [ - 'code' => 'adminapp.inicio', + 'code' => 'onticket.adminapp.inicio', 'label' => 'Inicio', 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/inicio', ], [ - 'code' => 'adminapp.catalog', + 'code' => 'onticket.adminapp.catalog', 'label' => 'Catálogo', 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/catalog', ], [ - 'code' => 'adminapp.combos', + 'code' => 'onticket.adminapp.combos', 'label' => 'Combos', 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/combos', ], [ - 'code' => 'adminapp.categories', + 'code' => 'onticket.adminapp.categories', 'label' => 'Categorías', 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/categories', ], [ - 'code' => 'adminapp.ventas', + 'code' => 'onticket.adminapp.ventas', 'label' => 'Ventas', 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/ventas', ], [ - 'code' => 'adminapp.staff', + 'code' => 'onticket.adminapp.staff', 'label' => 'Usuarios', 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/staff', ], [ - 'code' => 'adminapp.tickets', + 'code' => 'onticket.adminapp.tickets', 'label' => 'Tickets', 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/tickets', ], [ - 'code' => 'adminapp.fiesta-futbol-infantil.entradas', + 'code' => 'onticket.adminapp.fiesta-futbol-infantil.entradas', 'label' => 'Entradas', 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/entradas', ], [ - 'code' => 'adminapp.fiesta-futbol-infantil.alojamientos', + 'code' => 'onticket.adminapp.fiesta-futbol-infantil.alojamientos', 'label' => 'Alojamientos', 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/alojamientos', ], [ - 'code' => 'adminapp.fiesta-futbol-infantil.merchandising', + 'code' => 'onticket.adminapp.fiesta-futbol-infantil.merchandising', 'label' => 'Merchandising', 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/merchandising', ], [ - 'code' => 'adminapp.fiesta-futbol-infantil.comida', + 'code' => 'onticket.adminapp.fiesta-futbol-infantil.comida', 'label' => 'Comida', 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/comidas', ], [ - 'code' => 'adminapp.desfile.entradas', + 'code' => 'onticket.adminapp.desfile.entradas', 'label' => 'Entradas', 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/desfile/entradas', ], [ - 'code' => 'adminapp.desfile.reservas', + 'code' => 'onticket.adminapp.desfile.reservas', 'label' => 'Reserva de Tickets', 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/desfile/reservas', ], + [ + 'code' => 'shopit.adminapp.marca', + 'label' => 'Marca', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/marca', + ], + [ + 'code' => 'shopit.adminapp.contacto', + 'label' => 'Contacto', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/contacto', + ], + [ + 'code' => 'shopit.adminapp.productos', + 'label' => 'Productos', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/productos', + ], + [ + 'code' => 'shopit.adminapp.categorias', + 'label' => 'Categorías', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/categorias', + ], + [ + 'code' => 'shopit.adminapp.transacciones', + 'label' => 'Transacciones', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/transacciones', + ], + [ + 'code' => 'shopit.adminapp.reportes', + 'label' => 'Reportes', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/reportes', + ], + [ + 'code' => 'shopit.adminapp.usuarios_roles', + 'label' => 'Usuarios y roles', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/usuarios-roles', + ], [ 'code' => 'account', 'label' => 'Mi cuenta', @@ -255,6 +297,10 @@ class MenuSeeder extends Seeder ]) ->delete(); + Menu::query() + ->where('code', 'like', 'adminapp.%') + ->delete(); + $allRoleMenuCodes = Menu::query()->pluck('code'); $adminAppMenuCodes = Menu::query() ->where('code', 'main.adminapp') @@ -299,24 +345,32 @@ class MenuSeeder extends Seeder 'mutual_smep', ]; $ticketAdminMenuCodes = [ - 'adminapp.tickets', + 'onticket.adminapp.tickets', ]; $fiestaCategoryMenuCodes = [ - 'adminapp.fiesta-futbol-infantil.entradas', - 'adminapp.fiesta-futbol-infantil.alojamientos', - 'adminapp.fiesta-futbol-infantil.merchandising', - 'adminapp.fiesta-futbol-infantil.comida', + 'onticket.adminapp.fiesta-futbol-infantil.entradas', + 'onticket.adminapp.fiesta-futbol-infantil.alojamientos', + 'onticket.adminapp.fiesta-futbol-infantil.merchandising', + 'onticket.adminapp.fiesta-futbol-infantil.comida', ]; $fiestaExcludedAdminMenuCodes = [ - 'adminapp.inicio', - 'adminapp.catalog', - 'adminapp.categories', - 'adminapp.combos', + 'onticket.adminapp.inicio', + 'onticket.adminapp.catalog', + 'onticket.adminapp.categories', + 'onticket.adminapp.combos', ]; $desfileMenuCodes = [ - 'adminapp.desfile.entradas', - 'adminapp.desfile.reservas', + 'onticket.adminapp.desfile.entradas', + 'onticket.adminapp.desfile.reservas', ]; + $shopItAdminMenuCodes = Menu::query() + ->where('code', 'like', 'shopit.adminapp.%') + ->pluck('code') + ->all(); + $onTicketAdminMenuCodes = Menu::query() + ->where('code', 'like', 'onticket.adminapp.%') + ->pluck('code') + ->all(); $onTicketMenuCodes = [ 'event.index', 'event.category', @@ -406,6 +460,13 @@ class MenuSeeder extends Seeder foreach ($tenants as $tenant) { $menuCodes = $allMenus; + $menuCodes = array_diff( + $menuCodes, + $tenant->admin_website_type_code === 'shopit' + ? $onTicketAdminMenuCodes + : $shopItAdminMenuCodes, + ); + if ($tenant->codigo === 'sonder') { // Sonder NO tiene tickets $menuCodes = array_diff($menuCodes, ['account.tickets']); diff --git a/tests/Feature/Auth/ScannerMeControllerTest.php b/tests/Feature/Auth/ScannerMeControllerTest.php index 576725de..7f9564f8 100644 --- a/tests/Feature/Auth/ScannerMeControllerTest.php +++ b/tests/Feature/Auth/ScannerMeControllerTest.php @@ -43,7 +43,7 @@ class ScannerMeControllerTest extends TestCase 'route' => '/scanner/scan', ]); $foreign = Menu::query()->create([ - 'code' => 'adminapp.inicio', + 'code' => 'onticket.adminapp.inicio', 'label' => 'Administración', 'route' => '/admin/inicio', ]); diff --git a/tests/Feature/Desfile/EntryControllerTest.php b/tests/Feature/Desfile/EntryControllerTest.php index 7484a2c4..5d98972c 100644 --- a/tests/Feature/Desfile/EntryControllerTest.php +++ b/tests/Feature/Desfile/EntryControllerTest.php @@ -186,7 +186,7 @@ class EntryControllerTest extends TestCase 'admin_website_type_code' => 'onticket', ]); $menu = Menu::query()->firstOrCreate( - ['code' => 'adminapp.desfile.entradas'], + ['code' => 'onticket.adminapp.desfile.entradas'], ['label' => 'Entradas', 'route' => '/admin/desfile/entradas'], ); $tenant->menues()->attach($menu->code); diff --git a/tests/Feature/FiestaFutbolInfantil/AccommodationControllerTest.php b/tests/Feature/FiestaFutbolInfantil/AccommodationControllerTest.php index 3050ac09..5d28ecce 100644 --- a/tests/Feature/FiestaFutbolInfantil/AccommodationControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/AccommodationControllerTest.php @@ -236,7 +236,7 @@ class AccommodationControllerTest extends TestCase if ($withMenu) { $menu = Menu::query()->firstOrCreate( - ['code' => 'adminapp.fiesta-futbol-infantil.alojamientos'], + ['code' => 'onticket.adminapp.fiesta-futbol-infantil.alojamientos'], ['label' => 'Alojamientos', 'route' => '/admin/alojamientos'], ); $tenant->menues()->syncWithoutDetaching([$menu->code]); diff --git a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php index b6066fe9..8938333d 100644 --- a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php @@ -43,10 +43,10 @@ class EntryControllerTest extends TestCase { $tenant = $this->createFiestaTenant(); $categories = [ - 'entries' => ['Entradas', 'adminapp.fiesta-futbol-infantil.entradas'], - 'foods' => ['Comidas', 'adminapp.fiesta-futbol-infantil.comida'], - 'accommodations' => ['Alojamientos', 'adminapp.fiesta-futbol-infantil.alojamientos'], - 'merchandise' => ['Merchandising', 'adminapp.fiesta-futbol-infantil.merchandising'], + 'entries' => ['Entradas', 'onticket.adminapp.fiesta-futbol-infantil.entradas'], + 'foods' => ['Comidas', 'onticket.adminapp.fiesta-futbol-infantil.comida'], + 'accommodations' => ['Alojamientos', 'onticket.adminapp.fiesta-futbol-infantil.alojamientos'], + 'merchandise' => ['Merchandising', 'onticket.adminapp.fiesta-futbol-infantil.merchandising'], ]; foreach ($categories as [$label, $menuCode]) { @@ -405,7 +405,7 @@ class EntryControllerTest extends TestCase { $tenant = $this->createTenant('fiesta_futbol_infantil'); $menu = Menu::query()->create([ - 'code' => 'adminapp.fiesta-futbol-infantil.entradas', + 'code' => 'onticket.adminapp.fiesta-futbol-infantil.entradas', 'label' => 'Entradas', 'route' => '/admin/entradas', ]); diff --git a/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php index c0339163..f90b70ff 100644 --- a/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php @@ -367,7 +367,7 @@ class FoodControllerTest extends TestCase 'time_end' => '23:59', ]); $menu = Menu::query()->create([ - 'code' => 'adminapp.fiesta-futbol-infantil.comida', + 'code' => 'onticket.adminapp.fiesta-futbol-infantil.comida', 'label' => 'Comida', 'route' => '/admin/comidas', ]); diff --git a/tests/Feature/FiestaFutbolInfantil/MerchandiseControllerTest.php b/tests/Feature/FiestaFutbolInfantil/MerchandiseControllerTest.php index 7f8ddcf8..39aeaf3c 100644 --- a/tests/Feature/FiestaFutbolInfantil/MerchandiseControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/MerchandiseControllerTest.php @@ -293,7 +293,7 @@ class MerchandiseControllerTest extends TestCase ['value' => 'XL', 'label' => 'XL', 'sort_order' => 4], ]); $menu = Menu::query()->create([ - 'code' => 'adminapp.fiesta-futbol-infantil.merchandising', + 'code' => 'onticket.adminapp.fiesta-futbol-infantil.merchandising', 'label' => 'Merchandising', 'route' => '/admin/merchandising', ]); diff --git a/tests/Feature/Forms/AdminAppDesfileEntryReservationFormControllerTest.php b/tests/Feature/Forms/AdminAppDesfileEntryReservationFormControllerTest.php index d7a90e93..92b63c82 100644 --- a/tests/Feature/Forms/AdminAppDesfileEntryReservationFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppDesfileEntryReservationFormControllerTest.php @@ -135,7 +135,7 @@ class AdminAppDesfileEntryReservationFormControllerTest extends TestCase private function grantReservationsMenu(Tenant $tenant): void { $menu = Menu::query()->create([ - 'code' => 'adminapp.desfile.reservas', + 'code' => 'onticket.adminapp.desfile.reservas', 'label' => 'Reserva de Tickets', 'route' => '/admin/desfile/reservas', ]); diff --git a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php index b6d873b0..64d33a85 100644 --- a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php @@ -349,7 +349,7 @@ class AdminAppTicketFilterFormControllerTest extends TestCase private function grantTicketsMenu(Tenant $tenant): void { $menu = Menu::query()->create([ - 'code' => 'adminapp.tickets', + 'code' => 'onticket.adminapp.tickets', 'label' => 'Tickets', 'route' => '/admin/tickets', ]); diff --git a/tests/Feature/Migrations/SeparateAdminAppMenusByWebsiteTypeTest.php b/tests/Feature/Migrations/SeparateAdminAppMenusByWebsiteTypeTest.php new file mode 100644 index 00000000..be71df6d --- /dev/null +++ b/tests/Feature/Migrations/SeparateAdminAppMenusByWebsiteTypeTest.php @@ -0,0 +1,201 @@ +id(); + $table->string('code')->unique(); + $table->string('label'); + $table->string('parent_menu_code')->nullable(); + $table->string('content_type')->default('dynamic'); + $table->json('static_content_schema')->nullable(); + $table->string('route'); + $table->timestamps(); + }); + Schema::create('tenants', function (Blueprint $table): void { + $table->string('codigo')->primary(); + $table->string('admin_website_type_code')->nullable(); + }); + Schema::create('roles', function (Blueprint $table): void { + $table->string('codigo')->primary(); + }); + Schema::create('tenants_menues', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('menu_code'); + $table->json('static_content')->nullable(); + $table->timestamps(); + $table->unique(['tenant_code', 'menu_code']); + }); + Schema::create('roles_menues', function (Blueprint $table): void { + $table->id(); + $table->string('rol_codigo'); + $table->string('menu_codigo'); + $table->unique(['rol_codigo', 'menu_codigo']); + }); + + $now = now(); + DB::table('menues')->insert([ + [ + 'code' => 'main.adminapp', + 'label' => 'Administración', + 'parent_menu_code' => null, + 'route' => '/', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'code' => 'adminapp.inicio', + 'label' => 'Inicio', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/inicio', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'code' => 'adminapp.tickets', + 'label' => 'Tickets', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/tickets', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'code' => 'account', + 'label' => 'Mi cuenta', + 'parent_menu_code' => null, + 'route' => '/mi-cuenta', + 'created_at' => $now, + 'updated_at' => $now, + ], + ]); + DB::table('tenants')->insert([ + ['codigo' => 'sonder', 'admin_website_type_code' => 'shopit'], + ['codigo' => 'mutual_smep', 'admin_website_type_code' => 'shopit'], + ['codigo' => 'fiesta_futbol_infantil', 'admin_website_type_code' => 'onticket'], + ]); + DB::table('roles')->insert([ + ['codigo' => 'admin'], + ['codigo' => 'adminapp'], + ['codigo' => 'user'], + ]); + + foreach (['sonder', 'mutual_smep', 'fiesta_futbol_infantil'] as $tenantCode) { + foreach (['main.adminapp', 'adminapp.inicio', 'account'] as $menuCode) { + DB::table('tenants_menues')->insert([ + 'tenant_code' => $tenantCode, + 'menu_code' => $menuCode, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + DB::table('tenants_menues')->insert([ + 'tenant_code' => 'fiesta_futbol_infantil', + 'menu_code' => 'adminapp.tickets', + 'created_at' => $now, + 'updated_at' => $now, + ]); + DB::table('roles_menues')->insert([ + ['rol_codigo' => 'admin', 'menu_codigo' => 'adminapp.inicio'], + ['rol_codigo' => 'adminapp', 'menu_codigo' => 'adminapp.inicio'], + ['rol_codigo' => 'adminapp', 'menu_codigo' => 'adminapp.tickets'], + ]); + } + + protected function tearDown(): void + { + Schema::dropIfExists('roles_menues'); + Schema::dropIfExists('tenants_menues'); + Schema::dropIfExists('roles'); + Schema::dropIfExists('tenants'); + Schema::dropIfExists('menues'); + + parent::tearDown(); + } + + public function test_it_separates_admin_menus_by_admin_website_type_and_rolls_back(): void + { + $migration = require database_path( + 'migrations/2026_09_29_000000_separate_adminapp_menus_by_website_type.php' + ); + + $migration->up(); + $migration->up(); + + $this->assertDatabaseMissing('menues', ['code' => 'adminapp.inicio']); + $this->assertDatabaseHas('menues', ['code' => 'onticket.adminapp.inicio']); + $this->assertDatabaseHas('tenants_menues', [ + 'tenant_code' => 'fiesta_futbol_infantil', + 'menu_code' => 'onticket.adminapp.tickets', + ]); + $this->assertDatabaseHas('tenants_menues', [ + 'tenant_code' => 'fiesta_futbol_infantil', + 'menu_code' => 'onticket.adminapp.inicio', + ]); + + foreach (['sonder', 'mutual_smep'] as $tenantCode) { + $adminMenuCodes = DB::table('tenants_menues') + ->join('menues', 'menues.code', '=', 'tenants_menues.menu_code') + ->where('tenants_menues.tenant_code', $tenantCode) + ->where(fn ($query) => $query + ->where('menues.code', 'main.adminapp') + ->orWhere('menues.parent_menu_code', 'main.adminapp')) + ->orderBy('menues.code') + ->pluck('menues.code') + ->all(); + + $this->assertSame( + collect(['main.adminapp', ...self::SHOPIT_MENU_CODES])->sort()->values()->all(), + $adminMenuCodes, + ); + $this->assertDatabaseHas('tenants_menues', [ + 'tenant_code' => $tenantCode, + 'menu_code' => 'account', + ]); + } + + foreach (self::SHOPIT_MENU_CODES as $menuCode) { + $this->assertDatabaseHas('roles_menues', [ + 'rol_codigo' => 'adminapp', + 'menu_codigo' => $menuCode, + ]); + } + + $migration->down(); + + $this->assertDatabaseHas('menues', ['code' => 'adminapp.inicio']); + $this->assertDatabaseMissing('menues', ['code' => 'onticket.adminapp.inicio']); + $this->assertDatabaseMissing('menues', ['code' => 'shopit.adminapp.marca']); + $this->assertDatabaseHas('tenants_menues', [ + 'tenant_code' => 'sonder', + 'menu_code' => 'adminapp.inicio', + ]); + $this->assertDatabaseHas('tenants_menues', [ + 'tenant_code' => 'fiesta_futbol_infantil', + 'menu_code' => 'adminapp.tickets', + ]); + } +} diff --git a/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php b/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php index 6dc80cb2..cc804b65 100644 --- a/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php +++ b/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php @@ -135,7 +135,7 @@ class DesfilePuraTendenciaSeederTest extends TestCase $expectedMenus = DB::table('tenants_menues') ->where('tenant_code', 'fiesta_futbol_infantil') - ->where('menu_code', 'not like', 'adminapp.fiesta-futbol-infantil.%') + ->where('menu_code', 'not like', 'onticket.adminapp.fiesta-futbol-infantil.%') ->orderBy('menu_code') ->pluck('menu_code') ->all(); @@ -147,9 +147,9 @@ class DesfilePuraTendenciaSeederTest extends TestCase $this->assertSame($expectedMenus, $actualMenus); $this->assertContains('main.adminapp', $actualMenus); - $this->assertContains('adminapp.event', $actualMenus); + $this->assertContains('onticket.adminapp.event', $actualMenus); $this->assertContains('scanner.scan', $actualMenus); - $this->assertNotContains('adminapp.fiesta-futbol-infantil.entradas', $actualMenus); + $this->assertNotContains('onticket.adminapp.fiesta-futbol-infantil.entradas', $actualMenus); $catalogItem = DB::table('catalog_items') ->where('tenant_code', 'desfile_pura_tendencia') diff --git a/tests/Feature/Seeders/MenuSeederTest.php b/tests/Feature/Seeders/MenuSeederTest.php index 10d323c6..f7ef95ad 100644 --- a/tests/Feature/Seeders/MenuSeederTest.php +++ b/tests/Feature/Seeders/MenuSeederTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Seeders; use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Models\Role; use App\Domains\Core\Menu\Models\Menu; +use App\Domains\Core\Tenant\Models\AdminWebsiteType; use App\Domains\Core\Tenant\Models\Tenant; use App\Shared\Attachable\Enums\AttachmentType; use App\Shared\Attachable\Models\Attachment; @@ -25,21 +26,26 @@ class MenuSeederTest extends TestCase $this->seed(MenuSeeder::class); $expectedMenus = [ - 'adminapp.inicio' => ['Inicio', '/admin/inicio'], - 'adminapp.catalog' => ['Catálogo', '/admin/catalog'], - 'adminapp.categories' => ['Categorías', '/admin/categories'], - 'adminapp.combos' => ['Combos', '/admin/combos'], - 'adminapp.event' => ['Eventos', '/admin/event'], - 'adminapp.staff' => ['Staff', '/admin/staff'], - 'adminapp.ventas' => ['Ventas', '/admin/ventas'], + 'onticket.adminapp.inicio' => ['Inicio', '/admin/inicio'], + 'onticket.adminapp.catalog' => ['Catálogo', '/admin/catalog'], + 'onticket.adminapp.categories' => ['Categorías', '/admin/categories'], + 'onticket.adminapp.combos' => ['Combos', '/admin/combos'], + 'onticket.adminapp.event' => ['Eventos', '/admin/event'], + 'onticket.adminapp.staff' => ['Usuarios', '/admin/staff'], + 'onticket.adminapp.ventas' => ['Ventas', '/admin/ventas'], ]; $fiestaCategoryMenus = [ - 'adminapp.tickets' => ['Tickets', '/admin/tickets'], - 'adminapp.fiesta-futbol-infantil.entradas' => ['Entradas', '/admin/entradas'], - 'adminapp.fiesta-futbol-infantil.alojamientos' => ['Alojamientos', '/admin/alojamientos'], - 'adminapp.fiesta-futbol-infantil.merchandising' => ['Merchandising', '/admin/merchandising'], - 'adminapp.fiesta-futbol-infantil.comida' => ['Comida', '/admin/comidas'], + 'onticket.adminapp.tickets' => ['Tickets', '/admin/tickets'], + 'onticket.adminapp.fiesta-futbol-infantil.entradas' => ['Entradas', '/admin/entradas'], + 'onticket.adminapp.fiesta-futbol-infantil.alojamientos' => ['Alojamientos', '/admin/alojamientos'], + 'onticket.adminapp.fiesta-futbol-infantil.merchandising' => ['Merchandising', '/admin/merchandising'], + 'onticket.adminapp.fiesta-futbol-infantil.comida' => ['Comida', '/admin/comidas'], ]; + $desfileMenus = [ + 'onticket.adminapp.desfile.entradas' => ['Entradas', '/admin/desfile/entradas'], + 'onticket.adminapp.desfile.reservas' => ['Reserva de Tickets', '/admin/desfile/reservas'], + ]; + $shopItMenus = $this->shopItAdminMenus(); $adminApp = Menu::query() ->with('children') @@ -53,7 +59,8 @@ class MenuSeederTest extends TestCase collect(array_keys([ ...$expectedMenus, ...$fiestaCategoryMenus, - 'adminapp.desfile.entradas' => ['Entradas', '/admin/desfile/entradas'], + ...$desfileMenus, + ...$shopItMenus, ]))->sort()->values()->all(), $adminApp->children->pluck('code')->sort()->values()->all() ); @@ -88,10 +95,10 @@ class MenuSeederTest extends TestCase } foreach ([ - 'adminapp.inicio', - 'adminapp.catalog', - 'adminapp.categories', - 'adminapp.combos', + 'onticket.adminapp.inicio', + 'onticket.adminapp.catalog', + 'onticket.adminapp.categories', + 'onticket.adminapp.combos', ] as $code) { $this->assertFalse( $fiestaTenant->menues()->where('menues.code', $code)->exists() @@ -116,6 +123,50 @@ class MenuSeederTest extends TestCase ); } + public function test_it_assigns_only_shopit_admin_menus_to_shopit_admin_tenants(): void + { + AdminWebsiteType::query()->create([ + 'codigo' => 'shopit', + 'nombre' => 'ShopIt', + ]); + + $sonder = $this->createTenant('sonder', 'shopit'); + $mutualSmep = $this->createTenant('mutual_smep', 'shopit'); + $onTicket = $this->createTenant('onticket'); + + $this->seed(MenuSeeder::class); + + $shopItMenuCodes = array_keys($this->shopItAdminMenus()); + + foreach ([$sonder, $mutualSmep] as $tenant) { + $adminMenuCodes = $tenant->menues() + ->where(fn ($query) => $query + ->where('menues.code', 'main.adminapp') + ->orWhere('menues.parent_menu_code', 'main.adminapp')) + ->orderBy('menues.code') + ->pluck('menues.code') + ->all(); + + $this->assertSame( + collect(['main.adminapp', ...$shopItMenuCodes])->sort()->values()->all(), + $adminMenuCodes, + ); + } + + foreach ($this->shopItAdminMenus() as $code => [$label, $route]) { + $menu = Menu::query()->where('code', $code)->firstOrFail(); + + $this->assertSame($label, $menu->label); + $this->assertSame($route, $menu->route); + $this->assertSame('main.adminapp', $menu->parent_menu_code); + $this->assertFalse($onTicket->menues()->where('menues.code', $code)->exists()); + } + + $this->assertFalse( + Menu::query()->where('code', 'like', 'adminapp.%')->exists() + ); + } + public function test_it_assigns_menus_to_the_expected_roles(): void { $this->seed(AuthorizationSeeder::class); @@ -162,7 +213,7 @@ class MenuSeederTest extends TestCase $this->assertSame($userMenuCodes, $userRoleMenuCodes); $this->assertNotContains('main.adminapp', $userRoleMenuCodes); - $this->assertNotContains('adminapp.catalog', $userRoleMenuCodes); + $this->assertNotContains('onticket.adminapp.catalog', $userRoleMenuCodes); $scannerRoleMenuCodes = Role::query() ->where('codigo', RoleCode::Scanner->value) @@ -382,7 +433,7 @@ class MenuSeederTest extends TestCase ); } - private function createTenant(string $code): Tenant + private function createTenant(string $code, ?string $adminWebsiteTypeCode = null): Tenant { $headerLogo = $this->createAttachment("{$code}-header.png"); $footerLogo = $this->createAttachment("{$code}-footer.png"); @@ -391,6 +442,7 @@ class MenuSeederTest extends TestCase 'codigo' => $code, 'nombre' => $code, 'dominio' => "{$code}.example.com", + 'admin_website_type_code' => $adminWebsiteTypeCode, 'primary_color' => '#111111', 'secondary_color' => '#222222', 'danger_color' => '#333333', @@ -402,6 +454,20 @@ class MenuSeederTest extends TestCase ]); } + /** @return array */ + private function shopItAdminMenus(): array + { + return [ + 'shopit.adminapp.marca' => ['Marca', '/admin/marca'], + 'shopit.adminapp.contacto' => ['Contacto', '/admin/contacto'], + 'shopit.adminapp.productos' => ['Productos', '/admin/productos'], + 'shopit.adminapp.categorias' => ['Categorías', '/admin/categorias'], + 'shopit.adminapp.transacciones' => ['Transacciones', '/admin/transacciones'], + 'shopit.adminapp.reportes' => ['Reportes', '/admin/reportes'], + 'shopit.adminapp.usuarios_roles' => ['Usuarios y roles', '/admin/usuarios-roles'], + ]; + } + private function createAttachment(string $filename): Attachment { return Attachment::query()->create([ diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 20f83794..2ab90c87 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -1359,7 +1359,7 @@ class AdminAppTicketControllerTest extends TestCase private function grantTicketsMenu(Tenant $tenant): void { $menu = Menu::query()->create([ - 'code' => 'adminapp.tickets', + 'code' => 'onticket.adminapp.tickets', 'label' => 'Tickets', 'route' => '/admin/tickets', ]); From 794acc4814a78451b2d699a705340167aab89d25 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 29 Sep 2026 14:03:22 -0300 Subject: [PATCH 06/18] feat: add admin brand endpoint --- .../Controllers/AdminApp/BrandController.php | 18 ++ .../Resources/AdminApp/BrandResource.php | 66 +++++++ app/Domains/Core/Tenant/routes/adminapp.php | 2 + .../Tenant/AdminAppBrandControllerTest.php | 178 ++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 app/Domains/Core/Tenant/Controllers/AdminApp/BrandController.php create mode 100644 app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php create mode 100644 tests/Feature/Tenant/AdminAppBrandControllerTest.php diff --git a/app/Domains/Core/Tenant/Controllers/AdminApp/BrandController.php b/app/Domains/Core/Tenant/Controllers/AdminApp/BrandController.php new file mode 100644 index 00000000..c961bead --- /dev/null +++ b/app/Domains/Core/Tenant/Controllers/AdminApp/BrandController.php @@ -0,0 +1,18 @@ +user()->tenant()->firstOrFail(); + + return BrandResource::make($tenantInformationService->load($tenant)); + } +} diff --git a/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php b/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php new file mode 100644 index 00000000..70881e3d --- /dev/null +++ b/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php @@ -0,0 +1,66 @@ + */ + public function toArray(Request $request): array + { + return [ + 'nombre' => $this->nombre, + 'primary_color' => $this->primary_color, + 'footer_bg_color' => $this->footer_bg_color, + 'header_logo' => $this->headerLogo?->getTemporaryUrl(1440), + 'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440), + 'banner_images' => $this->bannerImages(), + ]; + } + + /** @return array> */ + private function bannerImages(): array + { + if ($this->storefront_website_type_code !== 'shopit') { + return []; + } + + $extra = $this->websiteExtras->first( + fn ($extra) => $extra->is_enabled && $extra->websiteTypeExtra->codigo === 'carousel' + ); + + if ($extra === null) { + return []; + } + + $images = $extra->resolvedConfig(); + + if (! is_array($images)) { + return []; + } + + return collect($images) + ->filter(fn ($image) => $image instanceof Attachment) + ->map(function (Attachment $image): array { + $desktopCrop = $image->cropVariants + ->firstWhere('variant', AttachmentCrop::DESKTOP); + + return [ + 'url' => ($desktopCrop?->croppedAttachment ?? $image)->getTemporaryUrl(1440), + 'original_url' => $image->getTemporaryUrl(1440), + 'crop' => $desktopCrop === null ? null : [ + 'crop_horizontal' => $desktopCrop->crop_horizontal, + 'crop_vertical' => $desktopCrop->crop_vertical, + ], + ]; + }) + ->values() + ->all(); + } +} diff --git a/app/Domains/Core/Tenant/routes/adminapp.php b/app/Domains/Core/Tenant/routes/adminapp.php index 7bdcbb9d..caf331d1 100644 --- a/app/Domains/Core/Tenant/routes/adminapp.php +++ b/app/Domains/Core/Tenant/routes/adminapp.php @@ -1,11 +1,13 @@ middleware(['auth:sanctum', 'adminapp.tenant']) ->group(function (): void { + Route::get('brand', BrandController::class); Route::get('website-extras', [WebsiteExtraController::class, 'show']); Route::get('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'showExtra']); Route::put('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'update']); diff --git a/tests/Feature/Tenant/AdminAppBrandControllerTest.php b/tests/Feature/Tenant/AdminAppBrandControllerTest.php new file mode 100644 index 00000000..5d03926b --- /dev/null +++ b/tests/Feature/Tenant/AdminAppBrandControllerTest.php @@ -0,0 +1,178 @@ +seed(AuthorizationSeeder::class); + Storage::fake('s3'); + } + + public function test_only_the_authenticated_adminapp_tenant_brand_is_returned(): void + { + $websiteType = $this->websiteType('shopit'); + $carousel = $websiteType->extras()->create([ + 'codigo' => 'carousel', + 'nombre' => 'Carrusel principal', + 'is_required' => false, + 'config_schema' => [ + 'request_rules' => ['$' => 'required|array'], + 'transforms' => ['$.*' => ['handler' => 'attachment', 'attachment_type' => 'image']], + ], + ]); + $header = $this->image('header.png'); + $footer = $this->image('footer.png'); + $first = $this->image('first.png'); + $second = $this->image('second.png'); + $cropped = $this->image('first-cropped.png'); + $first->cropVariants()->create([ + 'variant' => AttachmentCrop::DESKTOP, + 'crop_horizontal' => ['start_percentage' => 10, 'end_percentage' => 90], + 'crop_vertical' => ['start_percentage' => 20, 'end_percentage' => 80], + 'cropped_attachment_id' => $cropped->id, + ]); + $tenant = $this->tenant('acme', $websiteType, [ + 'nombre' => 'Acme', + 'primary_color' => '#112233', + 'footer_bg_color' => '#445566', + 'header_logo_id' => $header->id, + 'footer_logo_id' => $footer->id, + ]); + $tenant->websiteExtras()->create([ + 'website_type_extra_id' => $carousel->id, + 'config' => [$first->id, $second->id], + ]); + $other = $this->tenant('other', $websiteType, ['nombre' => 'Other']); + $other->websiteExtras()->create([ + 'website_type_extra_id' => $carousel->id, + 'config' => [$this->image('other.png')->id], + ]); + + Sanctum::actingAs($this->admin($tenant)); + + $response = $this->getJson('/api/v1/adminapp/tenant/brand') + ->assertOk() + ->assertJsonPath('data.nombre', 'Acme') + ->assertJsonPath('data.primary_color', '#112233') + ->assertJsonPath('data.footer_bg_color', '#445566') + ->assertJsonCount(2, 'data.banner_images'); + + foreach (['header_logo' => $header, 'footer_logo' => $footer] as $field => $image) { + $this->assertStringContainsString($image->path, $response->json("data.{$field}")); + } + foreach ([$first, $second] as $index => $image) { + $this->assertStringContainsString( + $image->path, + $response->json("data.banner_images.{$index}.original_url") + ); + } + $this->assertStringContainsString($cropped->path, $response->json('data.banner_images.0.url')); + $response->assertJsonPath('data.banner_images.0.crop.crop_horizontal.start_percentage', 10) + ->assertJsonPath('data.banner_images.0.crop.crop_vertical.end_percentage', 80) + ->assertJsonPath('data.banner_images.1.crop', null); + $this->assertStringNotContainsString('other.png', $response->getContent()); + } + + public function test_missing_and_non_shopit_carousels_return_an_empty_list(): void + { + $shopit = $this->websiteType('shopit'); + $tenant = $this->tenant('acme', $shopit); + Sanctum::actingAs($this->admin($tenant)); + + $this->getJson('/api/v1/adminapp/tenant/brand') + ->assertOk() + ->assertJsonPath('data.header_logo', null) + ->assertJsonPath('data.footer_logo', null) + ->assertJsonPath('data.banner_images', []); + + $otherType = $this->websiteType('other'); + $carousel = $otherType->extras()->create([ + 'codigo' => 'carousel', + 'nombre' => 'Other carousel', + 'is_required' => false, + 'config_schema' => [ + 'request_rules' => ['$' => 'required|array'], + 'transforms' => ['$.*' => ['handler' => 'attachment', 'attachment_type' => 'image']], + ], + ]); + $other = $this->tenant('other', $otherType); + $other->websiteExtras()->create([ + 'website_type_extra_id' => $carousel->id, + 'config' => [$this->image('other.png')->id], + ]); + Sanctum::actingAs($this->admin($other)); + + $this->getJson('/api/v1/adminapp/tenant/brand') + ->assertOk() + ->assertJsonPath('data.banner_images', []); + } + + public function test_the_brand_endpoint_requires_an_adminapp_session(): void + { + $this->getJson('/api/v1/adminapp/tenant/brand')->assertUnauthorized(); + + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::User->value, + 'tenant_codigo' => null, + ])); + + $this->getJson('/api/v1/adminapp/tenant/brand')->assertForbidden(); + } + + private function websiteType(string $code): StorefrontWebsiteType + { + return StorefrontWebsiteType::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + ]); + } + + private function tenant(string $code, StorefrontWebsiteType $websiteType, array $attributes = []): Tenant + { + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'storefront_website_type_code' => $websiteType->codigo, + ...$attributes, + ]); + } + + private function admin(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } + + private function image(string $filename): Attachment + { + return Attachment::query()->create([ + 'path' => "tenants/test/{$filename}", + 'filename' => $filename, + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + 'extension' => 'png', + ]); + } +} From 4bdd3effcc57f72579563bc63faad953b067f61a Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 29 Sep 2026 14:22:21 -0300 Subject: [PATCH 07/18] feat(brand): add header background color to brand resource --- app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php | 1 + tests/Feature/Tenant/AdminAppBrandControllerTest.php | 2 ++ 2 files changed, 3 insertions(+) diff --git a/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php b/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php index 70881e3d..e29aa824 100644 --- a/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php +++ b/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php @@ -17,6 +17,7 @@ class BrandResource extends JsonResource return [ 'nombre' => $this->nombre, 'primary_color' => $this->primary_color, + 'header_bg_color' => $this->header_bg_color, 'footer_bg_color' => $this->footer_bg_color, 'header_logo' => $this->headerLogo?->getTemporaryUrl(1440), 'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440), diff --git a/tests/Feature/Tenant/AdminAppBrandControllerTest.php b/tests/Feature/Tenant/AdminAppBrandControllerTest.php index 5d03926b..85a87686 100644 --- a/tests/Feature/Tenant/AdminAppBrandControllerTest.php +++ b/tests/Feature/Tenant/AdminAppBrandControllerTest.php @@ -53,6 +53,7 @@ class AdminAppBrandControllerTest extends TestCase $tenant = $this->tenant('acme', $websiteType, [ 'nombre' => 'Acme', 'primary_color' => '#112233', + 'header_bg_color' => '#223344', 'footer_bg_color' => '#445566', 'header_logo_id' => $header->id, 'footer_logo_id' => $footer->id, @@ -73,6 +74,7 @@ class AdminAppBrandControllerTest extends TestCase ->assertOk() ->assertJsonPath('data.nombre', 'Acme') ->assertJsonPath('data.primary_color', '#112233') + ->assertJsonPath('data.header_bg_color', '#223344') ->assertJsonPath('data.footer_bg_color', '#445566') ->assertJsonCount(2, 'data.banner_images'); From a9d9854e5e966f9b587710ed7bb2f9237b3b3c38 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 29 Sep 2026 14:28:53 -0300 Subject: [PATCH 08/18] feat(brand): update footer color mapping and adjust tests --- .../Resources/AdminApp/BrandResource.php | 22 ++++++++++++++- .../Tenant/AdminAppBrandControllerTest.php | 27 +++++++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php b/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php index e29aa824..0a05fb07 100644 --- a/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php +++ b/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php @@ -18,13 +18,33 @@ class BrandResource extends JsonResource 'nombre' => $this->nombre, 'primary_color' => $this->primary_color, 'header_bg_color' => $this->header_bg_color, - 'footer_bg_color' => $this->footer_bg_color, + 'footer_color' => $this->footerColor(), 'header_logo' => $this->headerLogo?->getTemporaryUrl(1440), 'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440), 'banner_images' => $this->bannerImages(), ]; } + private function footerColor(): string + { + $footerColor = strtolower(trim((string) $this->footer_bg_color)); + $primaryColor = strtolower(trim((string) $this->primary_color)); + + if ($footerColor !== '' && $footerColor === $primaryColor) { + return 'primary'; + } + + if (in_array($footerColor, ['#000', '#000000', 'black'], true)) { + return 'black'; + } + + if ($footerColor === '' || in_array($footerColor, ['#fff', '#ffffff', 'white'], true)) { + return 'white'; + } + + return 'primary'; + } + /** @return array> */ private function bannerImages(): array { diff --git a/tests/Feature/Tenant/AdminAppBrandControllerTest.php b/tests/Feature/Tenant/AdminAppBrandControllerTest.php index 85a87686..db864ac7 100644 --- a/tests/Feature/Tenant/AdminAppBrandControllerTest.php +++ b/tests/Feature/Tenant/AdminAppBrandControllerTest.php @@ -54,7 +54,7 @@ class AdminAppBrandControllerTest extends TestCase 'nombre' => 'Acme', 'primary_color' => '#112233', 'header_bg_color' => '#223344', - 'footer_bg_color' => '#445566', + 'footer_bg_color' => '#112233', 'header_logo_id' => $header->id, 'footer_logo_id' => $footer->id, ]); @@ -75,7 +75,7 @@ class AdminAppBrandControllerTest extends TestCase ->assertJsonPath('data.nombre', 'Acme') ->assertJsonPath('data.primary_color', '#112233') ->assertJsonPath('data.header_bg_color', '#223344') - ->assertJsonPath('data.footer_bg_color', '#445566') + ->assertJsonPath('data.footer_color', 'primary') ->assertJsonCount(2, 'data.banner_images'); foreach (['header_logo' => $header, 'footer_logo' => $footer] as $field => $image) { @@ -94,6 +94,29 @@ class AdminAppBrandControllerTest extends TestCase $this->assertStringNotContainsString('other.png', $response->getContent()); } + public function test_footer_color_is_mapped_to_a_supported_option(): void + { + $websiteType = $this->websiteType('shopit'); + $cases = [ + ['primary', '#112233', '#112233'], + ['white', '#112233', '#FFFFFF'], + ['black', '#112233', '#000000'], + ['primary', '#112233', '#445566'], + ]; + + foreach ($cases as $index => [$expected, $primaryColor, $footerColor]) { + $tenant = $this->tenant("tenant-{$index}", $websiteType, [ + 'primary_color' => $primaryColor, + 'footer_bg_color' => $footerColor, + ]); + Sanctum::actingAs($this->admin($tenant)); + + $this->getJson('/api/v1/adminapp/tenant/brand') + ->assertOk() + ->assertJsonPath('data.footer_color', $expected); + } + } + public function test_missing_and_non_shopit_carousels_return_an_empty_list(): void { $shopit = $this->websiteType('shopit'); From 3b6d6bf28bc5d9c3706ba0969824e85f2fb3f769 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 29 Sep 2026 15:49:18 -0300 Subject: [PATCH 09/18] feat(brand): implement update functionality and validation for brand details --- .../Controllers/AdminApp/BrandController.php | 15 +- .../Requests/AdminApp/UpdateBrandRequest.php | 31 ++++ .../Resources/AdminApp/BrandResource.php | 19 +- .../Core/Tenant/Services/BrandService.php | 165 ++++++++++++++++++ app/Domains/Core/Tenant/routes/adminapp.php | 3 +- .../Tenant/AdminAppBrandControllerTest.php | 60 +++++++ 6 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 app/Domains/Core/Tenant/Requests/AdminApp/UpdateBrandRequest.php create mode 100644 app/Domains/Core/Tenant/Services/BrandService.php diff --git a/app/Domains/Core/Tenant/Controllers/AdminApp/BrandController.php b/app/Domains/Core/Tenant/Controllers/AdminApp/BrandController.php index c961bead..31b7040b 100644 --- a/app/Domains/Core/Tenant/Controllers/AdminApp/BrandController.php +++ b/app/Domains/Core/Tenant/Controllers/AdminApp/BrandController.php @@ -2,17 +2,30 @@ namespace App\Domains\Core\Tenant\Controllers\AdminApp; +use App\Domains\Core\Tenant\Requests\AdminApp\UpdateBrandRequest; use App\Domains\Core\Tenant\Resources\AdminApp\BrandResource; +use App\Domains\Core\Tenant\Services\BrandService; use App\Domains\Core\Tenant\Services\TenantInformationService; use App\Http\Controllers\Controller; use Illuminate\Http\Request; class BrandController extends Controller { - public function __invoke(Request $request, TenantInformationService $tenantInformationService): BrandResource + public function show(Request $request, TenantInformationService $tenantInformationService): BrandResource { $tenant = $request->user()->tenant()->firstOrFail(); return BrandResource::make($tenantInformationService->load($tenant)); } + + public function update( + UpdateBrandRequest $request, + BrandService $brandService, + TenantInformationService $tenantInformationService, + ): BrandResource { + $tenant = $request->user()->tenant()->firstOrFail(); + $brandService->update($tenant, $request->validated()); + + return BrandResource::make($tenantInformationService->load($tenant->fresh())); + } } diff --git a/app/Domains/Core/Tenant/Requests/AdminApp/UpdateBrandRequest.php b/app/Domains/Core/Tenant/Requests/AdminApp/UpdateBrandRequest.php new file mode 100644 index 00000000..4d1d9ebd --- /dev/null +++ b/app/Domains/Core/Tenant/Requests/AdminApp/UpdateBrandRequest.php @@ -0,0 +1,31 @@ + */ + public function rules(): array + { + $shopit = $this->user()->tenant()->firstOrFail()->storefront_website_type_code === 'shopit'; + + return [ + 'nombre' => ['required', 'string', 'max:255'], + 'primary_color' => ['required', 'regex:/^#[0-9a-fA-F]{6}$/'], + 'footer_bg_color' => ['required', 'regex:/^#[0-9a-fA-F]{6}$/'], + 'header_logo' => ['present', 'nullable', new ImageOrBase64Rule], + 'footer_logo' => ['present', 'nullable', new ImageOrBase64Rule], + 'banner_images' => [$shopit ? 'present' : 'prohibited', 'array', 'max:10'], + 'banner_images.*' => ['required', new CroppedImageOrBase64Rule], + ]; + } +} diff --git a/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php b/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php index 0a05fb07..9f1de64c 100644 --- a/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php +++ b/app/Domains/Core/Tenant/Resources/AdminApp/BrandResource.php @@ -20,7 +20,10 @@ class BrandResource extends JsonResource 'header_bg_color' => $this->header_bg_color, 'footer_color' => $this->footerColor(), 'header_logo' => $this->headerLogo?->getTemporaryUrl(1440), + 'header_logo_key' => $this->headerLogo?->key, 'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440), + 'footer_logo_key' => $this->footerLogo?->key, + 'can_edit_carousel' => $this->canEditCarousel(), 'banner_images' => $this->bannerImages(), ]; } @@ -48,7 +51,7 @@ class BrandResource extends JsonResource /** @return array> */ private function bannerImages(): array { - if ($this->storefront_website_type_code !== 'shopit') { + if (! $this->canEditCarousel()) { return []; } @@ -73,6 +76,7 @@ class BrandResource extends JsonResource ->firstWhere('variant', AttachmentCrop::DESKTOP); return [ + 'key' => $image->key, 'url' => ($desktopCrop?->croppedAttachment ?? $image)->getTemporaryUrl(1440), 'original_url' => $image->getTemporaryUrl(1440), 'crop' => $desktopCrop === null ? null : [ @@ -84,4 +88,17 @@ class BrandResource extends JsonResource ->values() ->all(); } + + private function canEditCarousel(): bool + { + if ($this->storefront_website_type_code !== 'shopit') { + return false; + } + + $extra = $this->websiteExtras->first( + fn ($extra) => $extra->websiteTypeExtra->codigo === 'carousel' + ); + + return $extra === null || $extra->is_enabled; + } } diff --git a/app/Domains/Core/Tenant/Services/BrandService.php b/app/Domains/Core/Tenant/Services/BrandService.php new file mode 100644 index 00000000..0f940095 --- /dev/null +++ b/app/Domains/Core/Tenant/Services/BrandService.php @@ -0,0 +1,165 @@ + $data */ + public function update(Tenant $tenant, array $data): void + { + $carousel = $tenant->storefront_website_type_code === 'shopit' + ? $tenant->websiteExtras()->whereHas('websiteTypeExtra', + fn ($query) => $query->where('codigo', 'carousel'))->first() + : null; + $allowedBannerIds = is_array($carousel?->config) ? $carousel->config : []; + if ($carousel !== null && ! $carousel->is_enabled && ($data['banner_images'] ?? []) !== []) { + throw ValidationException::withMessages(['banner_images' => 'El carrusel está deshabilitado.']); + } + $seen = []; + + foreach (['header_logo' => $tenant->header_logo_id, 'footer_logo' => $tenant->footer_logo_id] as $field => $id) { + $this->existingImage($data[$field], $id === null ? [] : [$id], $field); + } + foreach ($data['banner_images'] ?? [] as $index => $item) { + $image = is_array($item) ? $item['image'] : $item; + $attachment = $this->existingImage($image, $allowedBannerIds, "banner_images.{$index}"); + if ($attachment !== null) { + if (isset($seen[$attachment->id])) { + throw ValidationException::withMessages(["banner_images.{$index}" => 'La imagen está repetida.']); + } + $seen[$attachment->id] = true; + } + } + + $created = []; + + try { + DB::transaction(function () use ($tenant, $data, $carousel, &$created): void { + $header = $this->logo($data['header_logo'], $tenant->header_logo_id, $tenant, $created); + $footer = $this->logo($data['footer_logo'], $tenant->footer_logo_id, $tenant, $created); + + $tenant->update([ + 'nombre' => trim($data['nombre']), + 'primary_color' => $data['primary_color'], + 'footer_bg_color' => $data['footer_bg_color'], + 'header_logo_id' => $header?->id, + 'footer_logo_id' => $footer?->id, + ]); + + if ($tenant->storefront_website_type_code !== 'shopit' + || ($carousel !== null && ! $carousel->is_enabled)) { + return; + } + + $definition = $this->websiteExtraService->definitionForTenant($tenant, 'carousel'); + $ids = []; + $recrops = []; + foreach ($data['banner_images'] as $item) { + $image = is_array($item) ? $item['image'] : $item; + $attachment = $this->existingImage($image, $carousel?->config ?? [], 'banner_images'); + $crops = $this->crops($item); + + if ($attachment === null) { + $attachment = $crops === null + ? $this->attachmentService->store($image, "tenants/{$tenant->codigo}/extras/carousel") + : $this->attachmentService->storeCroppedImageVariants( + $image, "tenants/{$tenant->codigo}/extras/carousel", $crops + ); + $created[] = $attachment; + } elseif ($crops !== null) { + $recrops[] = [$attachment, $crops]; + } + $ids[] = $attachment->id; + } + + $tenant->websiteExtras()->updateOrCreate( + ['website_type_extra_id' => $definition->id], + ['config' => $ids] + ); + + foreach ($recrops as [$attachment, $crops]) { + $this->attachmentService->updateImageCropVariants($attachment, $crops); + } + }); + } catch (Throwable $error) { + foreach ($created as $attachment) { + $paths = [$attachment->path]; + foreach ($attachment->cropVariants as $crop) { + if ($crop->croppedAttachment !== null) { + $paths[] = $crop->croppedAttachment->path; + } + } + Storage::disk('s3')->delete($paths); + } + throw $error; + } + } + + /** @param array $allowedIds */ + private function existingImage(mixed $value, array $allowedIds, string $field): ?Attachment + { + if (! is_string($value) || ! Str::isUuid($value)) { + return null; + } + + $attachment = Attachment::query()->where('key', $value)->first(); + if ($attachment === null || $attachment->type !== AttachmentType::Image + || ! in_array($attachment->id, $allowedIds, true)) { + throw ValidationException::withMessages([$field => 'La imagen no pertenece a esta marca.']); + } + + return $attachment; + } + + /** @param array $created */ + private function logo(mixed $value, ?int $currentId, Tenant $tenant, array &$created): ?Attachment + { + if ($value === null) { + return null; + } + + $existing = $this->existingImage($value, $currentId === null ? [] : [$currentId], 'logo'); + if ($existing !== null) { + return $existing; + } + + $attachment = $this->attachmentService->store($value, "tenants/{$tenant->codigo}/logos"); + $created[] = $attachment; + + return $attachment; + } + + /** @return array>|null */ + private function crops(mixed $item): ?array + { + if (! is_array($item)) { + return null; + } + + if (! isset($item['crop_horizontal'], $item['crop_vertical'])) { + return null; + } + + $crop = [ + 'crop_horizontal' => $item['crop_horizontal'], + 'crop_vertical' => $item['crop_vertical'], + ]; + + return ['desktop' => $crop, 'mobile' => $crop]; + } +} diff --git a/app/Domains/Core/Tenant/routes/adminapp.php b/app/Domains/Core/Tenant/routes/adminapp.php index caf331d1..99f744da 100644 --- a/app/Domains/Core/Tenant/routes/adminapp.php +++ b/app/Domains/Core/Tenant/routes/adminapp.php @@ -7,7 +7,8 @@ use Illuminate\Support\Facades\Route; Route::prefix('v1/adminapp/tenant') ->middleware(['auth:sanctum', 'adminapp.tenant']) ->group(function (): void { - Route::get('brand', BrandController::class); + Route::get('brand', [BrandController::class, 'show']); + Route::put('brand', [BrandController::class, 'update']); Route::get('website-extras', [WebsiteExtraController::class, 'show']); Route::get('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'showExtra']); Route::put('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'update']); diff --git a/tests/Feature/Tenant/AdminAppBrandControllerTest.php b/tests/Feature/Tenant/AdminAppBrandControllerTest.php index db864ac7..0395c8f7 100644 --- a/tests/Feature/Tenant/AdminAppBrandControllerTest.php +++ b/tests/Feature/Tenant/AdminAppBrandControllerTest.php @@ -11,6 +11,7 @@ use App\Shared\Attachable\Models\Attachment; use App\Shared\Attachable\Models\AttachmentCrop; use Database\Seeders\AuthorizationSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -80,8 +81,10 @@ class AdminAppBrandControllerTest extends TestCase foreach (['header_logo' => $header, 'footer_logo' => $footer] as $field => $image) { $this->assertStringContainsString($image->path, $response->json("data.{$field}")); + $response->assertJsonPath("data.{$field}_key", $image->key); } foreach ([$first, $second] as $index => $image) { + $response->assertJsonPath("data.banner_images.{$index}.key", $image->key); $this->assertStringContainsString( $image->path, $response->json("data.banner_images.{$index}.original_url") @@ -94,6 +97,63 @@ class AdminAppBrandControllerTest extends TestCase $this->assertStringNotContainsString('other.png', $response->getContent()); } + public function test_update_keeps_original_carousel_attachment_and_returns_its_crop(): void + { + $websiteType = $this->websiteType('shopit'); + $carousel = $websiteType->extras()->create([ + 'codigo' => 'carousel', 'nombre' => 'Carrusel', 'is_required' => false, + 'config_schema' => ['request_rules' => [], 'transforms' => [ + '$.*' => ['handler' => 'attachment', 'attachment_type' => 'image'], + ]], + ]); + $tenant = $this->tenant('acme', $websiteType, [ + 'primary_color' => '#112233', 'footer_bg_color' => '#112233', + ]); + Sanctum::actingAs($this->admin($tenant)); + $file = UploadedFile::fake()->image('banner.png', 400, 100); + $dataUrl = 'data:image/png;base64,'.base64_encode(file_get_contents($file->getRealPath())); + + $response = $this->putJson('/api/v1/adminapp/tenant/brand', [ + 'nombre' => 'Acme Nueva', 'primary_color' => '#abcdef', + 'footer_bg_color' => '#abcdef', 'header_logo' => null, 'footer_logo' => null, + 'banner_images' => [[ + 'image' => $dataUrl, + 'crop_horizontal' => ['start_percentage' => 10, 'end_percentage' => 90], + 'crop_vertical' => ['start_percentage' => 0, 'end_percentage' => 100], + ]], + ])->assertOk() + ->assertJsonPath('data.nombre', 'Acme Nueva') + ->assertJsonPath('data.footer_color', 'primary') + ->assertJsonPath('data.banner_images.0.crop.crop_horizontal.start_percentage', 10); + + $original = Attachment::query()->where('key', $response->json('data.banner_images.0.key'))->firstOrFail(); + $this->assertSame([$original->id], $tenant->websiteExtras() + ->where('website_type_extra_id', $carousel->id)->firstOrFail()->config); + $this->assertStringContainsString($original->path, $response->json('data.banner_images.0.original_url')); + $this->assertNotSame($response->json('data.banner_images.0.original_url'), + $response->json('data.banner_images.0.url')); + + $updated = $this->putJson('/api/v1/adminapp/tenant/brand', [ + 'nombre' => 'Acme Nueva', 'primary_color' => '#abcdef', + 'footer_bg_color' => '#abcdef', 'header_logo' => null, 'footer_logo' => null, + 'banner_images' => [[ + 'image' => $original->key, + 'crop_horizontal' => ['start_percentage' => 20, 'end_percentage' => 80], + 'crop_vertical' => ['start_percentage' => 0, 'end_percentage' => 100], + ]], + ])->assertOk() + ->assertJsonPath('data.banner_images.0.key', $original->key) + ->assertJsonPath('data.banner_images.0.crop.crop_horizontal.start_percentage', 20); + $this->assertSame([$original->id], $tenant->websiteExtras() + ->where('website_type_extra_id', $carousel->id)->firstOrFail()->config); + $this->assertNotSame($response->json('data.banner_images.0.url'), + $updated->json('data.banner_images.0.url')); + + $this->assertSame('#abcdef', $tenant->refresh()->footer_bg_color); + $tenant->update(['primary_color' => '#123456']); + $this->assertSame('#abcdef', $tenant->refresh()->footer_bg_color); + } + public function test_footer_color_is_mapped_to_a_supported_option(): void { $websiteType = $this->websiteType('shopit'); From 793e7be7565ae7b3e58f3d0a6a13cdb35c1f1f51 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 29 Sep 2026 16:35:07 -0300 Subject: [PATCH 10/18] fix: provision events before client migration --- database/seeders/FiestaTradicionArrufoSeeder.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/database/seeders/FiestaTradicionArrufoSeeder.php b/database/seeders/FiestaTradicionArrufoSeeder.php index 69263cb7..4127bb31 100644 --- a/database/seeders/FiestaTradicionArrufoSeeder.php +++ b/database/seeders/FiestaTradicionArrufoSeeder.php @@ -14,6 +14,7 @@ use App\Shared\Enums\FieldType; use Illuminate\Database\Seeder; use Illuminate\Http\UploadedFile; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; use RuntimeException; @@ -51,14 +52,19 @@ class FiestaTradicionArrufoSeeder extends Seeder ['tenant_code' => $tenant->codigo, 'title' => self::TITLE], ['published_at' => now()], ); - $event->update([ - 'client_id' => $client->id, + $eventData = [ 'subtitle' => 'Una noche para celebrar nuestras raíces y mantener viva la tradición gaucha.', 'description' => 'La 26.º Fiesta de la Tradición y 4.º Encuentro de Agrupaciones Gauchas reunirá a agrupaciones, artesanos, pilcheros y público en general para compartir una jornada dedicada a nuestras costumbres y cultura.' ."\n\n".'Un encuentro para disfrutar de la tradición, la identidad gaucha y el espíritu de camaradería, en el Predio de Doma del Club Unión Deportiva Arrufó.' ."\n\n".'Organiza: Biblioteca Popular Miguel Ángel Sosa.', 'location' => 'Predio de Doma del Club Unión Deportiva Arrufó', - ]); + ]; + + if (Schema::hasColumn('events', 'client_id')) { + $eventData['client_id'] = $client->id; + } + + $event->update($eventData); $eventImage = $event->attachment; if ($eventImage === null || ! Storage::disk('s3')->exists($eventImage->path)) { From 94db48467e77494ec08547e39575f50d6f1b2e0a Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 29 Sep 2026 16:35:07 -0300 Subject: [PATCH 11/18] feat: add tenant menu visibility --- .../Services/TenantBootstrapService.php | 12 +++++--- app/Domains/Core/Menu/Models/Menu.php | 2 +- app/Domains/Core/Menu/Models/TenantMenu.php | 2 ++ app/Domains/Core/Tenant/Models/Tenant.php | 2 +- app/Http/Middleware/EnsureTenantHasMenu.php | 3 ++ ...add_is_enabled_to_tenants_menues_table.php | 22 ++++++++++++++ ..._020000_make_help_terms_content_static.php | 29 +++++++++++++++++++ database/seeders/MenuSeeder.php | 5 +++- 8 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2026_09_29_010000_add_is_enabled_to_tenants_menues_table.php create mode 100644 database/migrations/2026_09_29_020000_make_help_terms_content_static.php diff --git a/app/Domains/Core/Bootstrap/Services/TenantBootstrapService.php b/app/Domains/Core/Bootstrap/Services/TenantBootstrapService.php index 9ccb8aa3..a89e3c05 100644 --- a/app/Domains/Core/Bootstrap/Services/TenantBootstrapService.php +++ b/app/Domains/Core/Bootstrap/Services/TenantBootstrapService.php @@ -33,10 +33,14 @@ class TenantBootstrapService $tenant, [ 'eventDateChanges', - 'menues' => fn ($query) => $query->whereHas( - 'roles', - fn ($query) => $query->where('codigo', RoleCode::User->value) - ), + 'menues' => fn ($query) => $query + ->where(fn ($query) => $query + ->where('menues.code', 'help.contact') + ->orWhere('tenants_menues.is_enabled', true)) + ->whereHas( + 'roles', + fn ($query) => $query->where('codigo', RoleCode::User->value) + ), $tenant->storefront_website_type_code === 'onticket_multi_event' ? 'eventCategories' : 'categories' => fn ($query) => $query->orderBy('nombre'), ] diff --git a/app/Domains/Core/Menu/Models/Menu.php b/app/Domains/Core/Menu/Models/Menu.php index 5862decd..87a53c58 100644 --- a/app/Domains/Core/Menu/Models/Menu.php +++ b/app/Domains/Core/Menu/Models/Menu.php @@ -76,7 +76,7 @@ class Menu extends Model 'codigo' ) ->using(TenantMenu::class) - ->withPivot('static_content') + ->withPivot('static_content', 'is_enabled') ->withTimestamps(); } diff --git a/app/Domains/Core/Menu/Models/TenantMenu.php b/app/Domains/Core/Menu/Models/TenantMenu.php index d6f4e93d..9eb96c15 100644 --- a/app/Domains/Core/Menu/Models/TenantMenu.php +++ b/app/Domains/Core/Menu/Models/TenantMenu.php @@ -16,12 +16,14 @@ class TenantMenu extends Pivot 'tenant_code', 'menu_code', 'static_content', + 'is_enabled', ]; protected function casts(): array { return [ 'static_content' => 'array', + 'is_enabled' => 'boolean', ]; } diff --git a/app/Domains/Core/Tenant/Models/Tenant.php b/app/Domains/Core/Tenant/Models/Tenant.php index c06ecc6c..532264ea 100644 --- a/app/Domains/Core/Tenant/Models/Tenant.php +++ b/app/Domains/Core/Tenant/Models/Tenant.php @@ -319,7 +319,7 @@ class Tenant extends Model 'code' ) ->using(TenantMenu::class) - ->withPivot('static_content') + ->withPivot('static_content', 'is_enabled') ->withTimestamps(); } } diff --git a/app/Http/Middleware/EnsureTenantHasMenu.php b/app/Http/Middleware/EnsureTenantHasMenu.php index 659b0657..ef7eda08 100644 --- a/app/Http/Middleware/EnsureTenantHasMenu.php +++ b/app/Http/Middleware/EnsureTenantHasMenu.php @@ -13,6 +13,9 @@ class EnsureTenantHasMenu $tenant = $request->user()?->tenant()->first(); $hasMenu = $tenant?->menues() ->where('menues.code', $menuCode) + ->where(fn ($query) => $query + ->where('menues.code', 'help.contact') + ->orWhere('tenants_menues.is_enabled', true)) ->exists() ?? false; abort_unless($hasMenu, 404); diff --git a/database/migrations/2026_09_29_010000_add_is_enabled_to_tenants_menues_table.php b/database/migrations/2026_09_29_010000_add_is_enabled_to_tenants_menues_table.php new file mode 100644 index 00000000..88b1cef9 --- /dev/null +++ b/database/migrations/2026_09_29_010000_add_is_enabled_to_tenants_menues_table.php @@ -0,0 +1,22 @@ +boolean('is_enabled')->default(true); + }); + } + + public function down(): void + { + Schema::table('tenants_menues', function (Blueprint $table): void { + $table->dropColumn('is_enabled'); + }); + } +}; diff --git a/database/migrations/2026_09_29_020000_make_help_terms_content_static.php b/database/migrations/2026_09_29_020000_make_help_terms_content_static.php new file mode 100644 index 00000000..a318a661 --- /dev/null +++ b/database/migrations/2026_09_29_020000_make_help_terms_content_static.php @@ -0,0 +1,29 @@ +where('code', 'help.terms-and-conditions') + ->update([ + 'content_type' => 'static', + 'static_content_schema' => json_encode([ + 'html' => 'present|nullable|string', + ], JSON_THROW_ON_ERROR), + ]); + } + + public function down(): void + { + DB::table('menues') + ->where('code', 'help.terms-and-conditions') + ->update([ + 'content_type' => 'dynamic', + 'static_content_schema' => null, + ]); + } +}; diff --git a/database/seeders/MenuSeeder.php b/database/seeders/MenuSeeder.php index 5974e633..95d0eabf 100644 --- a/database/seeders/MenuSeeder.php +++ b/database/seeders/MenuSeeder.php @@ -261,7 +261,10 @@ class MenuSeeder extends Seeder 'code' => 'help.terms-and-conditions', 'label' => 'Términos y condiciones', 'parent_menu_code' => 'help', - 'content_type' => Menu::CONTENT_TYPE_DYNAMIC, + 'content_type' => Menu::CONTENT_TYPE_STATIC, + 'static_content_schema' => [ + 'html' => 'present|nullable|string', + ], 'route' => '/ayuda/terminos-y-condiciones', ], ]; From 0303803f7586db4abde5780356fa1e1de7b3853c Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 29 Sep 2026 16:35:07 -0300 Subject: [PATCH 12/18] feat: manage tenant help menu settings --- .../AdminApp/HelpMenuController.php | 90 +++++++++ .../AdminApp/UpdateHelpMenuContentRequest.php | 31 +++ app/Domains/Core/Tenant/routes/adminapp.php | 4 + .../Tenant/AdminAppHelpMenuControllerTest.php | 176 ++++++++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 app/Domains/Core/Tenant/Controllers/AdminApp/HelpMenuController.php create mode 100644 app/Domains/Core/Tenant/Requests/AdminApp/UpdateHelpMenuContentRequest.php create mode 100644 tests/Feature/Tenant/AdminAppHelpMenuControllerTest.php diff --git a/app/Domains/Core/Tenant/Controllers/AdminApp/HelpMenuController.php b/app/Domains/Core/Tenant/Controllers/AdminApp/HelpMenuController.php new file mode 100644 index 00000000..e359b4a7 --- /dev/null +++ b/app/Domains/Core/Tenant/Controllers/AdminApp/HelpMenuController.php @@ -0,0 +1,90 @@ +user()->tenant()->firstOrFail(); + $menus = $tenant->menues() + ->whereIn('menues.code', self::MENU_CODES) + ->get() + ->keyBy('code'); + + return response()->json([ + 'data' => collect(self::MENU_CODES)->mapWithKeys( + fn (string $code): array => [$code => [ + 'code' => $code, + 'is_enabled' => (bool) ($menus->get($code)?->pivot?->is_enabled ?? false), + 'static_content' => $menus->get($code)?->pivot?->static_content + ?? $this->emptyContent($code), + ]] + ), + ]); + } + + public function toggle( + Request $request, + string $menuCode, + ): JsonResponse { + abort_unless(in_array($menuCode, self::MENU_CODES, true), 404); + + $tenant = $request->user()->tenant()->firstOrFail(); + $tenantMenu = $tenant->menues() + ->where('menues.code', $menuCode) + ->firstOrFail(); + $isEnabled = ! $tenantMenu->pivot->is_enabled; + + $tenant->menues()->updateExistingPivot($menuCode, [ + 'is_enabled' => $isEnabled, + ]); + + return response()->json([ + 'data' => [ + 'code' => $tenantMenu->code, + 'is_enabled' => $isEnabled, + ], + ]); + } + + public function updateContent( + UpdateHelpMenuContentRequest $request, + string $menuCode, + ): JsonResponse { + abort_unless(in_array($menuCode, self::MENU_CODES, true), 404); + + $tenant = $request->user()->tenant()->firstOrFail(); + $tenantMenu = $tenant->menues() + ->where('menues.code', $menuCode) + ->firstOrFail(); + $staticContent = $request->validated('static_content'); + + $tenant->menues()->updateExistingPivot($menuCode, [ + 'static_content' => $staticContent, + ]); + + return response()->json([ + 'data' => [ + 'code' => $tenantMenu->code, + 'is_enabled' => (bool) $tenantMenu->pivot->is_enabled, + 'static_content' => $staticContent, + ], + ]); + } + + private function emptyContent(string $menuCode): array + { + return $menuCode === 'help.faq' ? [] : ['html' => '']; + } +} diff --git a/app/Domains/Core/Tenant/Requests/AdminApp/UpdateHelpMenuContentRequest.php b/app/Domains/Core/Tenant/Requests/AdminApp/UpdateHelpMenuContentRequest.php new file mode 100644 index 00000000..a303ab12 --- /dev/null +++ b/app/Domains/Core/Tenant/Requests/AdminApp/UpdateHelpMenuContentRequest.php @@ -0,0 +1,31 @@ + */ + public function rules(): array + { + return match ($this->route('menuCode')) { + 'help.faq' => [ + 'static_content' => ['required', 'array'], + 'static_content.*.pregunta' => ['required', 'string'], + 'static_content.*.respuesta' => ['required', 'string'], + 'static_content.*.is_active' => ['required', 'boolean'], + ], + 'help.terms-and-conditions' => [ + 'static_content' => ['required', 'array'], + 'static_content.html' => ['present', 'nullable', 'string'], + ], + default => [], + }; + } +} diff --git a/app/Domains/Core/Tenant/routes/adminapp.php b/app/Domains/Core/Tenant/routes/adminapp.php index 99f744da..aa35510b 100644 --- a/app/Domains/Core/Tenant/routes/adminapp.php +++ b/app/Domains/Core/Tenant/routes/adminapp.php @@ -1,6 +1,7 @@ group(function (): void { Route::get('brand', [BrandController::class, 'show']); Route::put('brand', [BrandController::class, 'update']); + Route::get('help-menus', [HelpMenuController::class, 'index']); + Route::patch('help-menus/{menuCode}/toggle', [HelpMenuController::class, 'toggle']); + Route::put('help-menus/{menuCode}/content', [HelpMenuController::class, 'updateContent']); Route::get('website-extras', [WebsiteExtraController::class, 'show']); Route::get('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'showExtra']); Route::put('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'update']); diff --git a/tests/Feature/Tenant/AdminAppHelpMenuControllerTest.php b/tests/Feature/Tenant/AdminAppHelpMenuControllerTest.php new file mode 100644 index 00000000..118a697e --- /dev/null +++ b/tests/Feature/Tenant/AdminAppHelpMenuControllerTest.php @@ -0,0 +1,176 @@ +seed(AuthorizationSeeder::class); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/tenant/help-menus') + ->assertUnauthorized(); + } + + public function test_adminapp_user_can_read_help_menu_states(): void + { + [$tenant] = $this->tenantWithHelpMenus(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $response = $this->getJson('/api/v1/adminapp/tenant/help-menus') + ->assertOk(); + + $states = $response->json('data'); + $this->assertFalse($states['help.faq']['is_enabled']); + $this->assertTrue($states['help.terms-and-conditions']['is_enabled']); + $this->assertSame('¿Cómo compro?', $states['help.faq']['static_content'][0]['pregunta']); + $this->assertSame(['html' => ''], $states['help.terms-and-conditions']['static_content']); + $this->assertArrayNotHasKey('help.contact', $states); + } + + public function test_toggling_a_help_menu_preserves_its_static_content(): void + { + [$tenant, $faq, $content] = $this->tenantWithHelpMenus(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->patchJson('/api/v1/adminapp/tenant/help-menus/help.faq/toggle') + ->assertOk() + ->assertJsonPath('data.code', 'help.faq') + ->assertJsonPath('data.is_enabled', true); + + $pivot = $tenant->menues()->where('menues.code', $faq->code)->firstOrFail()->pivot; + + $this->assertTrue($pivot->is_enabled); + $this->assertSame($content, $pivot->static_content); + } + + public function test_an_unrelated_menu_cannot_be_toggled_from_the_help_endpoint(): void + { + [$tenant] = $this->tenantWithHelpMenus(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->patchJson('/api/v1/adminapp/tenant/help-menus/catalog/toggle') + ->assertNotFound(); + } + + public function test_contact_menu_cannot_be_toggled(): void + { + [$tenant] = $this->tenantWithHelpMenus(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->patchJson('/api/v1/adminapp/tenant/help-menus/help.contact/toggle') + ->assertNotFound(); + + $this->assertTrue( + $tenant->menues()->where('menues.code', 'help.contact')->firstOrFail()->pivot->is_enabled + ); + } + + public function test_adminapp_user_can_update_faq_content_without_changing_its_state(): void + { + [$tenant] = $this->tenantWithHelpMenus(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + $content = [[ + 'pregunta' => '¿Cuál es el horario?', + 'respuesta' => 'De lunes a viernes.', + 'is_active' => true, + ]]; + + $this->putJson('/api/v1/adminapp/tenant/help-menus/help.faq/content', [ + 'static_content' => $content, + ]) + ->assertOk() + ->assertJsonPath('data.is_enabled', false) + ->assertJsonPath('data.static_content.0.pregunta', '¿Cuál es el horario?'); + + $pivot = $tenant->menues()->where('menues.code', 'help.faq')->firstOrFail()->pivot; + $this->assertFalse($pivot->is_enabled); + $this->assertSame($content, $pivot->static_content); + } + + public function test_adminapp_user_can_update_terms_content(): void + { + [$tenant] = $this->tenantWithHelpMenus(); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->putJson('/api/v1/adminapp/tenant/help-menus/help.terms-and-conditions/content', [ + 'static_content' => ['html' => '

Nuevos términos

'], + ]) + ->assertOk() + ->assertJsonPath('data.static_content.html', '

Nuevos términos

'); + + $this->assertSame( + ['html' => '

Nuevos términos

'], + $tenant->menues() + ->where('menues.code', 'help.terms-and-conditions') + ->firstOrFail() + ->pivot + ->static_content + ); + } + + /** @return array{Tenant, Menu, array>} */ + private function tenantWithHelpMenus(): array + { + $tenant = Tenant::query()->create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.test', + ]); + $content = [[ + 'pregunta' => '¿Cómo compro?', + 'respuesta' => 'Desde el catálogo.', + 'is_active' => true, + ]]; + $faq = Menu::query()->create([ + 'code' => 'help.faq', + 'label' => 'Preguntas frecuentes', + 'content_type' => Menu::CONTENT_TYPE_STATIC, + 'static_content_schema' => ['*.pregunta' => 'required|string'], + 'route' => '/ayuda/preguntas-frecuentes', + ]); + $contact = Menu::query()->create([ + 'code' => 'help.contact', + 'label' => 'Contacto', + 'route' => '/ayuda/contacto', + ]); + $terms = Menu::query()->create([ + 'code' => 'help.terms-and-conditions', + 'label' => 'Términos y condiciones', + 'route' => '/ayuda/terminos-y-condiciones', + ]); + + $tenant->menues()->attach($faq->code, [ + 'static_content' => $content, + 'is_enabled' => false, + ]); + $tenant->menues()->attach($contact->code, ['is_enabled' => true]); + $tenant->menues()->attach($terms->code, ['is_enabled' => true]); + + return [$tenant, $faq, $content]; + } + + private function createAdminAppUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } +} From df1965514479e0621c87bd2382d627d2e550e4c3 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 30 Sep 2026 09:26:12 -0300 Subject: [PATCH 13/18] feat(address): implement Address and TenantAddress models with relationships feat(migration): create addresses and tenant_addresses tables feat(migration): add data migration for copying contact menu addresses test: add ContactAddressesTest for address and tenant address functionality --- app/Domains/Core/Address/Models/Address.php | 29 +++ .../Core/Address/Models/TenantAddress.php | 33 +++ app/Domains/Core/Tenant/Models/Tenant.php | 19 ++ .../Tenant/Services/TenantAddressService.php | 26 ++ ..._addresses_and_tenant_addresses_tables.php | 57 +++++ ..._30_000100_copy_contact_menu_addresses.php | 108 +++++++++ .../Migrations/ContactAddressesTest.php | 229 ++++++++++++++++++ 7 files changed, 501 insertions(+) create mode 100644 app/Domains/Core/Address/Models/Address.php create mode 100644 app/Domains/Core/Address/Models/TenantAddress.php create mode 100644 app/Domains/Core/Tenant/Services/TenantAddressService.php create mode 100644 database/migrations/2026_09_30_000000_create_addresses_and_tenant_addresses_tables.php create mode 100644 database/migrations/2026_09_30_000100_copy_contact_menu_addresses.php create mode 100644 tests/Feature/Migrations/ContactAddressesTest.php diff --git a/app/Domains/Core/Address/Models/Address.php b/app/Domains/Core/Address/Models/Address.php new file mode 100644 index 00000000..a914966b --- /dev/null +++ b/app/Domains/Core/Address/Models/Address.php @@ -0,0 +1,29 @@ + 'decimal:7', + 'longitude' => 'decimal:7', + ]; + } + + /** @return BelongsToMany */ + public function tenants(): BelongsToMany + { + return $this->belongsToMany(Tenant::class, 'tenant_addresses', 'address_id', 'tenant_codigo', 'id', 'codigo') + ->using(TenantAddress::class) + ->withPivot(['id', 'is_main']) + ->withTimestamps(); + } +} diff --git a/app/Domains/Core/Address/Models/TenantAddress.php b/app/Domains/Core/Address/Models/TenantAddress.php new file mode 100644 index 00000000..31694d31 --- /dev/null +++ b/app/Domains/Core/Address/Models/TenantAddress.php @@ -0,0 +1,33 @@ + 'boolean']; + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); + } + + /** @return BelongsTo */ + public function address(): BelongsTo + { + return $this->belongsTo(Address::class); + } +} diff --git a/app/Domains/Core/Tenant/Models/Tenant.php b/app/Domains/Core/Tenant/Models/Tenant.php index 532264ea..da58333a 100644 --- a/app/Domains/Core/Tenant/Models/Tenant.php +++ b/app/Domains/Core/Tenant/Models/Tenant.php @@ -6,6 +6,8 @@ use App\Domains\Commerce\Catalog\Enums\GroupLayout; use App\Domains\Commerce\Catalog\Enums\ProductLayout; use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\Category; +use App\Domains\Core\Address\Models\Address; +use App\Domains\Core\Address\Models\TenantAddress; use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Menu\Models\Menu; use App\Domains\Core\Menu\Models\TenantMenu; @@ -22,6 +24,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOneThrough; use Illuminate\Support\Facades\Schema; #[Fillable([ @@ -308,6 +311,22 @@ class Tenant extends Model return $this->hasMany(WebsiteExtra::class, 'website_code', 'codigo'); } + /** @return BelongsToMany */ + public function addresses(): BelongsToMany + { + return $this->belongsToMany(Address::class, 'tenant_addresses', 'tenant_codigo', 'address_id', 'codigo', 'id') + ->using(TenantAddress::class) + ->withPivot(['id', 'is_main']) + ->withTimestamps(); + } + + /** @return HasOneThrough */ + public function mainAddress(): HasOneThrough + { + return $this->hasOneThrough(Address::class, TenantAddress::class, 'tenant_codigo', 'id', 'codigo', 'address_id') + ->where('tenant_addresses.is_main', true); + } + public function menues(): BelongsToMany { return $this->belongsToMany( diff --git a/app/Domains/Core/Tenant/Services/TenantAddressService.php b/app/Domains/Core/Tenant/Services/TenantAddressService.php new file mode 100644 index 00000000..3eabfe78 --- /dev/null +++ b/app/Domains/Core/Tenant/Services/TenantAddressService.php @@ -0,0 +1,26 @@ +where('codigo', $tenant->codigo)->lockForUpdate()->firstOrFail(); + $tenant->addresses()->where('addresses.id', $address->id)->firstOrFail(); + + DB::table('tenant_addresses')->where('tenant_codigo', $tenant->codigo) + ->where('is_main', true)->update(['is_main' => false, 'updated_at' => now()]); + $tenant->addresses()->updateExistingPivot($address->id, ['is_main' => true]); + }); + + $tenant->unsetRelation('addresses'); + $tenant->unsetRelation('mainAddress'); + } +} diff --git a/database/migrations/2026_09_30_000000_create_addresses_and_tenant_addresses_tables.php b/database/migrations/2026_09_30_000000_create_addresses_and_tenant_addresses_tables.php new file mode 100644 index 00000000..a0c68632 --- /dev/null +++ b/database/migrations/2026_09_30_000000_create_addresses_and_tenant_addresses_tables.php @@ -0,0 +1,57 @@ +getDriverName(); + + if (! in_array($driver, ['mysql', 'mariadb', 'sqlite', 'pgsql', 'sqlsrv'], true)) { + throw new RuntimeException("Unsupported address database driver: {$driver}"); + } + + Schema::create('addresses', function (Blueprint $table): void { + $table->id(); + $table->string('label'); + $table->text('address_text'); + $table->decimal('latitude', 10, 7)->nullable(); + $table->decimal('longitude', 10, 7)->nullable(); + $table->timestamps(); + }); + + Schema::create('tenant_addresses', function (Blueprint $table) use ($driver): void { + $table->id(); + $table->string('tenant_codigo'); + $table->foreignId('address_id')->constrained('addresses')->cascadeOnDelete(); + $table->boolean('is_main')->default(false); + $table->timestamps(); + + $table->foreign('tenant_codigo')->references('codigo')->on('tenants')->cascadeOnDelete(); + $table->unique(['tenant_codigo', 'address_id']); + + if (in_array($driver, ['mysql', 'mariadb'], true)) { + // NULL allows many secondary addresses, while a tenant code can occur only once. + $table->string('main_tenant_codigo')->nullable() + ->virtualAs('CASE WHEN is_main = 1 THEN tenant_codigo ELSE NULL END'); + $table->unique('main_tenant_codigo', 'tenant_addresses_one_main_per_tenant'); + } + }); + + if (! in_array($driver, ['mysql', 'mariadb'], true)) { + $condition = $driver === 'pgsql' ? 'is_main = true' : 'is_main = 1'; + DB::statement('CREATE UNIQUE INDEX tenant_addresses_one_main_per_tenant ' + ."ON tenant_addresses (tenant_codigo) WHERE {$condition}"); + } + } + + public function down(): void + { + Schema::dropIfExists('tenant_addresses'); + Schema::dropIfExists('addresses'); + } +}; diff --git a/database/migrations/2026_09_30_000100_copy_contact_menu_addresses.php b/database/migrations/2026_09_30_000100_copy_contact_menu_addresses.php new file mode 100644 index 00000000..7fa0a230 --- /dev/null +++ b/database/migrations/2026_09_30_000100_copy_contact_menu_addresses.php @@ -0,0 +1,108 @@ +where('menu_code', 'help.contact') + ->whereNotNull('static_content') + ->orderBy('id') + ->chunkById(100, function ($menus): void { + foreach ($menus as $menu) { + $content = json_decode($menu->static_content, true, flags: JSON_THROW_ON_ERROR); + + if ($content === null) { + continue; + } + + if (! is_array($content) || ! is_array($content['locations'] ?? [])) { + throw new RuntimeException("Invalid contact locations for tenant {$menu->tenant_code}"); + } + + foreach ($content['locations'] ?? [] as $location) { + if (! is_array($location) || ! is_array($location['addresses'] ?? [])) { + throw new RuntimeException("Invalid contact addresses for tenant {$menu->tenant_code}"); + } + + foreach ($location['addresses'] ?? [] as $address) { + $this->copyAddress($menu->tenant_code, $location, $address); + } + } + } + }); + }); + } + + private function copyAddress(string $tenantCode, array $location, mixed $address): void + { + if (! is_array($address) || ! is_string($address['address'] ?? null) + || trim($address['address']) === '') { + throw new RuntimeException("Invalid contact address text for tenant {$tenantCode}"); + } + + $coordinates = $address['coordinates'] ?? null; + + if ($coordinates !== null && (! is_array($coordinates) || count($coordinates) !== 2 + || ! is_numeric($coordinates[0] ?? null) || ! is_numeric($coordinates[1] ?? null) + || ! is_finite((float) $coordinates[0]) || ! is_finite((float) $coordinates[1]) + || abs((float) $coordinates[0]) > 90 || abs((float) $coordinates[1]) > 180)) { + throw new RuntimeException("Invalid contact coordinates for tenant {$tenantCode}"); + } + + $label = $address['label'] ?? $location['label'] ?? 'Dirección'; + + if (! is_string($label) || trim($label) === '' || mb_strlen($label) > 255) { + throw new RuntimeException("Invalid contact address label for tenant {$tenantCode}"); + } + + $values = [ + 'label' => $label, + 'address_text' => $address['address'], + 'latitude' => $coordinates === null ? null : number_format((float) $coordinates[0], 7, '.', ''), + 'longitude' => $coordinates === null ? null : number_format((float) $coordinates[1], 7, '.', ''), + ]; + + // Reuse only within this tenant; equal legacy addresses in different tenants stay independent. + $existing = DB::table('addresses') + ->join('tenant_addresses', 'tenant_addresses.address_id', '=', 'addresses.id') + ->where('tenant_addresses.tenant_codigo', $tenantCode) + ->where(array_combine( + array_map(fn (string $field): string => 'addresses.'.$field, array_keys($values)), + array_values($values), + )) + ->first(['addresses.id', 'tenant_addresses.id as link_id']); + + if ($existing !== null) { + return; + } + + $now = now(); + $isMain = ! DB::table('tenant_addresses') + ->where('tenant_codigo', $tenantCode) + ->where('is_main', true) + ->exists(); + $addressId = DB::table('addresses')->insertGetId([ + ...$values, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('tenant_addresses')->insert([ + 'tenant_codigo' => $tenantCode, + 'address_id' => $addressId, + 'is_main' => $isMain, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + public function down(): void + { + // Keep copies: they may already have been edited or reused. Legacy menus were never changed. + } +}; diff --git a/tests/Feature/Migrations/ContactAddressesTest.php b/tests/Feature/Migrations/ContactAddressesTest.php new file mode 100644 index 00000000..3b40317e --- /dev/null +++ b/tests/Feature/Migrations/ContactAddressesTest.php @@ -0,0 +1,229 @@ +id(); + $table->string('codigo')->unique(); + $table->string('address')->nullable(); + $table->timestamps(); + }); + Schema::create('tenants_menues', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('menu_code'); + $table->json('static_content')->nullable(); + $table->boolean('is_enabled')->default(true); + }); + DB::table('tenants')->insert([ + ['codigo' => 'alpha', 'address' => 'Dirección anterior'], + ['codigo' => 'beta', 'address' => null], + ['codigo' => 'empty', 'address' => null], + ]); + + $this->schemaMigration()->up(); + } + + public function test_it_copies_all_contact_locations_without_changing_legacy_data_and_can_run_again(): void + { + $sharedAddress = [ + 'label' => 'Sede principal', + 'address' => 'San Lorenzo 1543, Rosario', + 'coordinates' => [-32.9431184, -60.6437991], + ]; + $this->contact('alpha', [ + 'rosario' => ['label' => 'Rosario', 'addresses' => [$sharedAddress]], + 'santa_fe' => ['label' => 'Santa Fe', 'addresses' => [ + ['label' => 'Sucursal', 'address' => 'San Martín 100', 'coordinates' => [0, 0]], + ['label' => 'Sin mapa', 'address' => 'San Martín 200'], + ]], + ], enabled: false); + $this->contact('beta', ['rosario' => ['addresses' => [$sharedAddress]]]); + $this->contact('empty', []); + DB::table('tenants_menues')->insert([ + 'tenant_code' => 'empty', + 'menu_code' => 'help.faq', + 'static_content' => json_encode(['locations' => [['addresses' => [$sharedAddress]]]]), + ]); + $oldMenus = DB::table('tenants_menues')->orderBy('id')->get()->toArray(); + + $migration = $this->dataMigration(); + $migration->up(); + $migration->up(); + + $this->assertDatabaseCount('addresses', 4); + $this->assertDatabaseCount('tenant_addresses', 4); + $this->assertDatabaseHas('addresses', [ + 'label' => 'Sede principal', + 'address_text' => 'San Lorenzo 1543, Rosario', + 'latitude' => -32.9431184, + 'longitude' => -60.6437991, + ]); + $this->assertDatabaseHas('addresses', ['label' => 'Sucursal', 'latitude' => 0, 'longitude' => 0]); + $this->assertDatabaseHas('addresses', ['label' => 'Sin mapa', 'latitude' => null, 'longitude' => null]); + $this->assertEquals($oldMenus, DB::table('tenants_menues')->orderBy('id')->get()->toArray()); + $this->assertDatabaseHas('tenants', ['codigo' => 'alpha', 'address' => 'Dirección anterior']); + + $alpha = Tenant::query()->where('codigo', 'alpha')->firstOrFail(); + $beta = Tenant::query()->where('codigo', 'beta')->firstOrFail(); + $this->assertCount(3, $alpha->addresses); + $this->assertTrue($alpha->addresses->first()->pivot->is_main); + $this->assertInstanceOf(TenantAddress::class, $alpha->addresses->first()->pivot); + $this->assertSame('Sede principal', $alpha->mainAddress->label); + $this->assertSame('-32.9431184', $alpha->mainAddress->latitude); + $this->assertNotSame($alpha->mainAddress->id, $beta->mainAddress->id); + $this->assertSame('alpha', $alpha->mainAddress->tenants->sole()->codigo); + $this->assertNull(Tenant::query()->where('codigo', 'empty')->firstOrFail()->mainAddress); + $this->assertSame(2, DB::table('tenant_addresses')->where('is_main', true)->count()); + + $migration->down(); + $this->assertDatabaseCount('addresses', 4); + $this->assertEquals($oldMenus, DB::table('tenants_menues')->orderBy('id')->get()->toArray()); + } + + public function test_a_tenant_can_have_many_secondary_addresses_but_only_one_main(): void + { + $tenant = Tenant::query()->where('codigo', 'alpha')->firstOrFail(); + $addresses = $this->addresses(3); + $tenant->addresses()->attach($addresses[0], ['is_main' => true]); + $tenant->addresses()->attach([$addresses[1], $addresses[2]], ['is_main' => false]); + $this->assertCount(3, $tenant->addresses); + + $this->expectException(QueryException::class); + $tenant->addresses()->updateExistingPivot($addresses[1], ['is_main' => true]); + } + + public function test_it_prevents_duplicate_links(): void + { + $tenant = Tenant::query()->where('codigo', 'alpha')->firstOrFail(); + $address = $this->addresses(1)[0]; + $tenant->addresses()->attach($address); + + $this->expectException(QueryException::class); + $tenant->addresses()->attach($address); + } + + public function test_main_address_is_specific_to_each_tenant_and_can_be_switched(): void + { + $alpha = Tenant::query()->where('codigo', 'alpha')->firstOrFail(); + $beta = Tenant::query()->where('codigo', 'beta')->firstOrFail(); + $addresses = $this->addresses(2); + $alpha->addresses()->attach($addresses[0], ['is_main' => true]); + $alpha->addresses()->attach($addresses[1]); + $beta->addresses()->attach($addresses[0], ['is_main' => true]); + $this->assertSame($addresses[0], $alpha->mainAddress->id); + + app(TenantAddressService::class)->setMainAddress($alpha, Address::findOrFail($addresses[1])); + + $this->assertSame($addresses[1], $alpha->mainAddress->id); + $this->assertSame($addresses[0], $beta->mainAddress->id); + $this->assertSame(2, DB::table('tenant_addresses')->where('is_main', true)->count()); + $loaded = Tenant::query()->with('mainAddress')->whereIn('codigo', ['alpha', 'beta'])->get()->keyBy('codigo'); + $this->assertSame($addresses[1], $loaded['alpha']->mainAddress->id); + $this->assertSame($addresses[0], $loaded['beta']->mainAddress->id); + } + + public function test_switching_to_an_unlinked_address_preserves_the_previous_main(): void + { + $tenant = Tenant::query()->where('codigo', 'alpha')->firstOrFail(); + $addresses = $this->addresses(2); + $tenant->addresses()->attach($addresses[0], ['is_main' => true]); + + try { + app(TenantAddressService::class)->setMainAddress($tenant, Address::findOrFail($addresses[1])); + $this->fail('An unlinked address must be rejected.'); + } catch (ModelNotFoundException) { + $this->assertSame($addresses[0], $tenant->fresh()->mainAddress->id); + } + } + + public function test_invalid_coordinates_roll_back_all_copies_and_preserve_source_data(): void + { + $this->contact('alpha', [['addresses' => [ + ['label' => 'Válida', 'address' => 'Primera 100', 'coordinates' => [-32, -60]], + ['label' => 'Inválida', 'address' => 'Segunda 200', 'coordinates' => [-91, -60]], + ]]]); + $oldContent = DB::table('tenants_menues')->value('static_content'); + + try { + $this->dataMigration()->up(); + $this->fail('Invalid latitude must not be silently imported.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString('alpha', $exception->getMessage()); + $this->assertDatabaseCount('addresses', 0); + $this->assertDatabaseCount('tenant_addresses', 0); + $this->assertSame($oldContent, DB::table('tenants_menues')->value('static_content')); + } + } + + public function test_deleting_a_tenant_removes_links_but_keeps_shared_addresses(): void + { + $address = $this->addresses(1)[0]; + foreach (['alpha', 'beta'] as $code) { + Tenant::query()->where('codigo', $code)->firstOrFail()->addresses()->attach($address, ['is_main' => true]); + } + Tenant::query()->where('codigo', 'alpha')->delete(); + $this->assertDatabaseCount('tenant_addresses', 1); + $this->assertDatabaseCount('addresses', 1); + + Address::findOrFail($address)->delete(); + $this->assertDatabaseCount('tenant_addresses', 0); + } + + public function test_schema_can_be_rolled_back_without_removing_legacy_tables(): void + { + $this->schemaMigration()->down(); + $this->assertFalse(Schema::hasTable('addresses')); + $this->assertFalse(Schema::hasTable('tenant_addresses')); + $this->assertTrue(Schema::hasTable('tenants_menues')); + $this->assertTrue(Schema::hasColumn('tenants', 'address')); + } + + private function contact(string $code, array $locations, bool $enabled = true): void + { + DB::table('tenants_menues')->insert([ + 'tenant_code' => $code, + 'menu_code' => 'help.contact', + 'static_content' => json_encode(['phone' => '+54 123', 'locations' => $locations], JSON_THROW_ON_ERROR), + 'is_enabled' => $enabled, + ]); + } + + private function addresses(int $count): array + { + $ids = []; + for ($i = 0; $i < $count; $i++) { + $ids[] = Address::create(['label' => "Sucursal {$i}", 'address_text' => "Calle {$i}"])->id; + } + + return $ids; + } + + private function schemaMigration(): Migration + { + return require database_path('migrations/2026_09_30_000000_create_addresses_and_tenant_addresses_tables.php'); + } + + private function dataMigration(): Migration + { + return require database_path('migrations/2026_09_30_000100_copy_contact_menu_addresses.php'); + } +} From 5d5ee7d2a554abef82be46c005d23520d867b0f8 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 30 Sep 2026 09:26:19 -0300 Subject: [PATCH 14/18] feat(event): add address relationship and migration for event locations --- app/Domains/Core/Address/Models/Address.php | 8 + app/Domains/Ticketing/Event/Models/Event.php | 11 +- .../Ticketing/Event/documentacion/README.md | 14 ++ ..._09_30_000200_add_address_id_to_events.php | 22 ++ ...0300_copy_event_locations_to_addresses.php | 57 +++++ .../Feature/Migrations/EventAddressesTest.php | 203 ++++++++++++++++++ 6 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_09_30_000200_add_address_id_to_events.php create mode 100644 database/migrations/2026_09_30_000300_copy_event_locations_to_addresses.php create mode 100644 tests/Feature/Migrations/EventAddressesTest.php diff --git a/app/Domains/Core/Address/Models/Address.php b/app/Domains/Core/Address/Models/Address.php index a914966b..0fc7805b 100644 --- a/app/Domains/Core/Address/Models/Address.php +++ b/app/Domains/Core/Address/Models/Address.php @@ -3,8 +3,10 @@ namespace App\Domains\Core\Address\Models; use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; class Address extends Model { @@ -18,6 +20,12 @@ class Address extends Model ]; } + /** @return HasMany */ + public function events(): HasMany + { + return $this->hasMany(Event::class); + } + /** @return BelongsToMany */ public function tenants(): BelongsToMany { diff --git a/app/Domains/Ticketing/Event/Models/Event.php b/app/Domains/Ticketing/Event/Models/Event.php index ca3e042d..b3d0b819 100644 --- a/app/Domains/Ticketing/Event/Models/Event.php +++ b/app/Domains/Ticketing/Event/Models/Event.php @@ -3,6 +3,7 @@ namespace App\Domains\Ticketing\Event\Models; use App\Domains\Commerce\Catalog\Models\CatalogItem; +use App\Domains\Core\Address\Models\Address; use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Tenant\Models\SocialMedia; use App\Domains\Core\Tenant\Models\Tenant; @@ -15,14 +16,14 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['client_id', 'tenant_code', 'slug', 'event_category_id', 'title', 'subtitle', 'description', 'location', 'exact_location', 'date_text', 'published_at', 'attachment_id'])] +#[Fillable(['client_id', 'tenant_code', 'slug', 'event_category_id', 'title', 'subtitle', 'description', 'address_id', 'location', 'exact_location', 'date_text', 'published_at', 'attachment_id'])] class Event extends Model { use HasFactory; protected function casts(): array { - return ['client_id' => 'integer', 'published_at' => 'datetime', 'exact_location' => 'array']; + return ['client_id' => 'integer', 'address_id' => 'integer', 'published_at' => 'datetime', 'exact_location' => 'array']; } /** @return BelongsTo */ @@ -37,6 +38,12 @@ class Event extends Model return $this->belongsTo(Client::class); } + /** @return BelongsTo */ + public function address(): BelongsTo + { + return $this->belongsTo(Address::class); + } + public function effectiveClient(): Client { return $this->client ?? $this->tenant->client; diff --git a/app/Domains/Ticketing/Event/documentacion/README.md b/app/Domains/Ticketing/Event/documentacion/README.md index 97ac8b42..453bf021 100644 --- a/app/Domains/Ticketing/Event/documentacion/README.md +++ b/app/Domains/Ticketing/Event/documentacion/README.md @@ -13,6 +13,20 @@ mediante `event_id`. El catálogo `social_media` define las plataformas; `exact_location` guarda opcionalmente un objeto JSON con `latitude` (-90 a 90) y `longitude` (-180 a 180). `location` sigue siendo la dirección legible. +`events.address_id` es una referencia opcional a `addresses.id`: `Event::address()` +obtiene su dirección y `Address::events()` permite reutilizar una dirección en varios +eventos. Al borrar una dirección, el vínculo queda en `NULL` y se conserva el evento. + +La migración `2026_09_30_000200_add_address_id_to_events` agrega el vínculo. +`2026_09_30_000300_copy_event_locations_to_addresses` copia `location` a +`address_text` y las coordenadas de `exact_location` a `latitude` y `longitude`, con +el label `Ubicación del evento`. Solo copia eventos sin `address_id` y con texto de +ubicación; no modifica los campos ni los timestamps originales, ni vincula la +dirección al tenant. Si faltan texto y coordenadas, el vínculo queda en `NULL`. +Los datos inválidos, incluidas coordenadas sin texto de dirección, detienen la +copia y revierten las inserciones para evitar una migración parcial. Las migraciones +deben ejecutarse explícitamente por quien administra la base. + La migración `2026_09_18_000500_restore_events` crea un evento por cada tenant que tenía datos de evento o fechas, y migra las fechas y redes correspondientes. `2026_09_18_000600_associate_active_event_social_media` copia al evento activo las diff --git a/database/migrations/2026_09_30_000200_add_address_id_to_events.php b/database/migrations/2026_09_30_000200_add_address_id_to_events.php new file mode 100644 index 00000000..e7289792 --- /dev/null +++ b/database/migrations/2026_09_30_000200_add_address_id_to_events.php @@ -0,0 +1,22 @@ +foreignId('address_id')->nullable()->constrained('addresses')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('events', function (Blueprint $table): void { + $table->dropConstrainedForeignId('address_id'); + }); + } +}; diff --git a/database/migrations/2026_09_30_000300_copy_event_locations_to_addresses.php b/database/migrations/2026_09_30_000300_copy_event_locations_to_addresses.php new file mode 100644 index 00000000..04c7fac6 --- /dev/null +++ b/database/migrations/2026_09_30_000300_copy_event_locations_to_addresses.php @@ -0,0 +1,57 @@ +whereNull('address_id')->orderBy('id')->lockForUpdate() + ->chunkById(100, function ($events): void { + foreach ($events as $event) { + $coordinates = $event->exact_location === null + ? null + : json_decode($event->exact_location, true, flags: JSON_THROW_ON_ERROR); + + if ($coordinates !== null && (! is_array($coordinates) + || ! is_numeric($coordinates['latitude'] ?? null) + || ! is_numeric($coordinates['longitude'] ?? null) + || ! is_finite((float) $coordinates['latitude']) + || ! is_finite((float) $coordinates['longitude']) + || abs((float) $coordinates['latitude']) > 90 + || abs((float) $coordinates['longitude']) > 180)) { + throw new RuntimeException("Invalid location coordinates for event {$event->id}"); + } + + if ($event->location === null || trim($event->location) === '') { + if ($coordinates !== null) { + throw new RuntimeException("Missing address text for event {$event->id}"); + } + + continue; + } + + $now = now(); + $addressId = DB::table('addresses')->insertGetId([ + 'label' => 'Ubicación del evento', + 'address_text' => $event->location, + 'latitude' => $coordinates === null ? null : $coordinates['latitude'], + 'longitude' => $coordinates === null ? null : $coordinates['longitude'], + 'created_at' => $now, + 'updated_at' => $now, + ]); + + // Update only the new link, preserving the original fields and timestamps. + DB::table('events')->where('id', $event->id)->update(['address_id' => $addressId]); + } + }); + }); + } + + public function down(): void + { + // Copies may already have been edited or shared; keep them and the legacy data. + } +}; diff --git a/tests/Feature/Migrations/EventAddressesTest.php b/tests/Feature/Migrations/EventAddressesTest.php new file mode 100644 index 00000000..9a7ff335 --- /dev/null +++ b/tests/Feature/Migrations/EventAddressesTest.php @@ -0,0 +1,203 @@ +id(); + $table->string('codigo')->unique(); + }); + Schema::create('events', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('title'); + $table->string('location')->nullable(); + $table->json('exact_location')->nullable(); + $table->timestamps(); + }); + $addresses = require database_path('migrations/2026_09_30_000000_create_addresses_and_tenant_addresses_tables.php'); + $addresses->up(); + $this->schemaMigration()->up(); + } + + public function test_it_copies_event_locations_and_coordinates_and_preserves_legacy_data(): void + { + $precise = $this->event('Predio de Doma', ['latitude' => -32.9431184, 'longitude' => -60.6437991]); + $noMap = $this->event('Salón Luz y Fuerza'); + $zero = $this->event('Punto cero', ['latitude' => 0, 'longitude' => 0]); + $missing = $this->event(null); + $blank = $this->event(' '); + $before = DB::table('events')->orderBy('id')->get()->map(fn (object $event): array => [ + 'id' => $event->id, + 'tenant_code' => $event->tenant_code, + 'title' => $event->title, + 'location' => $event->location, + 'exact_location' => $event->exact_location, + 'created_at' => $event->created_at, + 'updated_at' => $event->updated_at, + ])->all(); + + $migration = $this->dataMigration(); + $migration->up(); + $migration->up(); + + $this->assertDatabaseCount('addresses', 3); + $this->assertDatabaseCount('tenant_addresses', 0); + $event = Event::findOrFail($precise); + $this->assertSame('Ubicación del evento', $event->address->label); + $this->assertSame('Predio de Doma', $event->address->address_text); + $this->assertSame('-32.9431184', $event->address->latitude); + $this->assertSame('-60.6437991', $event->address->longitude); + $this->assertNull(Event::findOrFail($noMap)->address->latitude); + $this->assertNull(Event::findOrFail($noMap)->address->longitude); + $this->assertSame('0.0000000', Event::findOrFail($zero)->address->latitude); + $this->assertSame('0.0000000', Event::findOrFail($zero)->address->longitude); + $this->assertNull(Event::findOrFail($missing)->address); + $this->assertNull(Event::findOrFail($blank)->address_id); + foreach ($before as $original) { + $this->assertDatabaseHas('events', $original); + } + + $migration->down(); + $this->assertDatabaseCount('addresses', 3); + $this->assertSame($event->address_id, Event::findOrFail($precise)->address_id); + } + + public function test_it_keeps_existing_links_and_creates_independent_copies_for_unlinked_events(): void + { + $original = Address::create(['label' => 'Existente', 'address_text' => 'Lugar elegido']); + $linked = $this->event('Texto anterior'); + DB::table('events')->where('id', $linked)->update(['address_id' => $original->id]); + $first = $this->event('Mismo predio'); + $second = $this->event('Mismo predio'); + + $this->dataMigration()->up(); + + $this->assertDatabaseCount('addresses', 3); + $this->assertSame($original->id, Event::findOrFail($linked)->address_id); + $this->assertNotSame(Event::findOrFail($first)->address_id, Event::findOrFail($second)->address_id); + $this->assertSame('Lugar elegido', $original->fresh()->address_text); + } + + public function test_multiple_events_can_share_one_address_through_the_model_relations(): void + { + $address = Address::create(['label' => 'Teatro', 'address_text' => 'San Martín 123']); + $first = Event::findOrFail($this->event('Texto original')); + $first->address()->associate($address); + $first->save(); + $second = Event::findOrFail($this->event('Otro evento')); + $second->fill(['address_id' => (string) $address->id])->save(); + + $this->assertSame($address->id, $first->fresh()->address->id); + $this->assertSame($address->id, $second->fresh()->address_id); + $this->assertSame([$first->id, $second->id], $address->events()->orderBy('id')->pluck('id')->all()); + $loaded = Event::with('address')->orderBy('id')->get(); + $this->assertSame($address->id, $loaded->last()->address->id); + + $first->delete(); + $this->assertDatabaseCount('addresses', 1); + $address->delete(); + $this->assertDatabaseHas('events', ['id' => $second->id, 'address_id' => null, 'location' => 'Otro evento']); + } + + public function test_the_foreign_key_rejects_an_unknown_address(): void + { + $event = Event::findOrFail($this->event('Predio')); + + $this->expectException(QueryException::class); + $event->update(['address_id' => 9999]); + } + + #[DataProvider('invalidCoordinates')] + public function test_invalid_coordinates_roll_back_all_copies(string $coordinates): void + { + $valid = $this->event('Primero', ['latitude' => -32, 'longitude' => -60]); + $invalid = $this->event('Segundo'); + DB::table('events')->where('id', $invalid)->update(['exact_location' => $coordinates]); + + try { + $this->dataMigration()->up(); + $this->fail('Invalid coordinates must stop the migration.'); + } catch (\JsonException|RuntimeException) { + $this->assertDatabaseCount('addresses', 0); + $this->assertNull(Event::findOrFail($valid)->address_id); + $this->assertNull(Event::findOrFail($invalid)->address_id); + $this->assertSame($coordinates, DB::table('events')->where('id', $invalid)->value('exact_location')); + } + } + + public static function invalidCoordinates(): array + { + return [ + 'latitude out of range' => ['{"latitude":-91,"longitude":-60}'], + 'longitude out of range' => ['{"latitude":-32,"longitude":181}'], + 'missing longitude' => ['{"latitude":-32}'], + 'nonnumeric latitude' => ['{"latitude":"unknown","longitude":-60}'], + 'invalid JSON' => ['not-json'], + ]; + } + + public function test_coordinates_without_address_text_stop_the_copy_instead_of_losing_data(): void + { + $id = $this->event(null, ['latitude' => -32, 'longitude' => -60]); + + try { + $this->dataMigration()->up(); + $this->fail('Coordinates without address text require correction.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString("Missing address text for event {$id}", $exception->getMessage()); + $this->assertDatabaseCount('addresses', 0); + $this->assertNull(Event::findOrFail($id)->address_id); + } + } + + public function test_schema_rollback_preserves_events_legacy_fields_and_copied_addresses(): void + { + $id = $this->event('Predio', ['latitude' => -32, 'longitude' => -60]); + $this->dataMigration()->up(); + $this->schemaMigration()->down(); + + $this->assertFalse(Schema::hasColumn('events', 'address_id')); + $this->assertDatabaseHas('events', ['id' => $id, 'location' => 'Predio']); + $this->assertDatabaseCount('addresses', 1); + $this->assertTrue(Schema::hasColumn('events', 'exact_location')); + } + + private function event(?string $location, ?array $coordinates = null): int + { + return DB::table('events')->insertGetId([ + 'tenant_code' => 'alpha', + 'title' => 'Evento', + 'location' => $location, + 'exact_location' => $coordinates === null ? null : json_encode($coordinates, JSON_THROW_ON_ERROR), + 'created_at' => '2026-09-01 12:00:00', + 'updated_at' => '2026-09-02 12:00:00', + ]); + } + + private function schemaMigration(): Migration + { + return require database_path('migrations/2026_09_30_000200_add_address_id_to_events.php'); + } + + private function dataMigration(): Migration + { + return require database_path('migrations/2026_09_30_000300_copy_event_locations_to_addresses.php'); + } +} From 6588d9f1a8fd0a9f82687545b9773721967576ff Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 30 Sep 2026 10:59:41 -0300 Subject: [PATCH 15/18] feat: enhance address handling with coordinates and integrate into event resources --- app/Domains/Core/Address/Models/Address.php | 10 ++ .../Address/Resources/AddressResource.php | 22 +++ app/Domains/Core/Tenant/Models/Tenant.php | 3 +- .../Resources/TenantAddressResource.php | 14 ++ .../Core/Tenant/Resources/TenantResource.php | 4 + .../Services/TenantInformationService.php | 3 + .../Event/Resources/EventResource.php | 2 + .../Event/Resources/PublicEventResource.php | 2 + .../Event/Services/EventAddressService.php | 37 ++++ .../Ticketing/Event/Services/EventService.php | 12 +- .../Event/Services/PublicEventService.php | 6 +- .../Ticketing/Event/documentacion/README.md | 12 ++ .../Address/AddressPresentationTest.php | 163 ++++++++++++++++++ 13 files changed, 282 insertions(+), 8 deletions(-) create mode 100644 app/Domains/Core/Address/Resources/AddressResource.php create mode 100644 app/Domains/Core/Tenant/Resources/TenantAddressResource.php create mode 100644 app/Domains/Ticketing/Event/Services/EventAddressService.php create mode 100644 tests/Feature/Address/AddressPresentationTest.php diff --git a/app/Domains/Core/Address/Models/Address.php b/app/Domains/Core/Address/Models/Address.php index 0fc7805b..dae2798f 100644 --- a/app/Domains/Core/Address/Models/Address.php +++ b/app/Domains/Core/Address/Models/Address.php @@ -20,6 +20,16 @@ class Address extends Model ]; } + /** @return array{latitude: float, longitude: float}|null */ + public function coordinates(): ?array + { + if ($this->latitude === null || $this->longitude === null) { + return null; + } + + return ['latitude' => (float) $this->latitude, 'longitude' => (float) $this->longitude]; + } + /** @return HasMany */ public function events(): HasMany { diff --git a/app/Domains/Core/Address/Resources/AddressResource.php b/app/Domains/Core/Address/Resources/AddressResource.php new file mode 100644 index 00000000..ff696da7 --- /dev/null +++ b/app/Domains/Core/Address/Resources/AddressResource.php @@ -0,0 +1,22 @@ + $this->id, + 'label' => $this->label, + 'address_text' => $this->address_text, + 'latitude' => $this->latitude === null ? null : (float) $this->latitude, + 'longitude' => $this->longitude === null ? null : (float) $this->longitude, + ]; + } +} diff --git a/app/Domains/Core/Tenant/Models/Tenant.php b/app/Domains/Core/Tenant/Models/Tenant.php index da58333a..9b824a8a 100644 --- a/app/Domains/Core/Tenant/Models/Tenant.php +++ b/app/Domains/Core/Tenant/Models/Tenant.php @@ -317,7 +317,8 @@ class Tenant extends Model return $this->belongsToMany(Address::class, 'tenant_addresses', 'tenant_codigo', 'address_id', 'codigo', 'id') ->using(TenantAddress::class) ->withPivot(['id', 'is_main']) - ->withTimestamps(); + ->withTimestamps() + ->orderByPivot('id'); } /** @return HasOneThrough */ diff --git a/app/Domains/Core/Tenant/Resources/TenantAddressResource.php b/app/Domains/Core/Tenant/Resources/TenantAddressResource.php new file mode 100644 index 00000000..7313c4d1 --- /dev/null +++ b/app/Domains/Core/Tenant/Resources/TenantAddressResource.php @@ -0,0 +1,14 @@ + (bool) $this->pivot->is_main]; + } +} diff --git a/app/Domains/Core/Tenant/Resources/TenantResource.php b/app/Domains/Core/Tenant/Resources/TenantResource.php index 8378c5d1..f4272ae3 100644 --- a/app/Domains/Core/Tenant/Resources/TenantResource.php +++ b/app/Domains/Core/Tenant/Resources/TenantResource.php @@ -3,6 +3,7 @@ namespace App\Domains\Core\Tenant\Resources; use App\Domains\Commerce\Catalog\Models\Category; +use App\Domains\Core\Address\Resources\AddressResource; use App\Domains\Core\Menu\Models\Menu; use App\Domains\Core\Tenant\Models\StorefrontWebsiteType; use App\Domains\Core\Tenant\Models\Tenant; @@ -41,6 +42,8 @@ class TenantResource extends JsonResource 'site_title' => $this->site_title ?? 'ShopitFront', 'asset_url' => config('filesystems.disks.s3.url'), 'address' => $this->address, + 'addresses' => $this->whenLoaded('addresses', fn () => TenantAddressResource::collection($this->addresses)), + 'main_address' => $this->whenLoaded('mainAddress', fn () => AddressResource::make($this->mainAddress)), 'phone' => $this->phone, 'favicon' => $this->favicon?->getTemporaryUrl(1440), 'primary_color' => $this->primary_color, @@ -127,6 +130,7 @@ class TenantResource extends JsonResource 'subtitle' => $event->subtitle, 'description' => $event->description, 'location' => $event->location, + 'address' => AddressResource::make($event->address), 'date_text' => $event->date_text, 'dates' => $event->dates ->filter(fn (EventDate $date): bool => $date->rescheduled_to_event_date_id === null) diff --git a/app/Domains/Core/Tenant/Services/TenantInformationService.php b/app/Domains/Core/Tenant/Services/TenantInformationService.php index fcbdcba6..acdfba63 100644 --- a/app/Domains/Core/Tenant/Services/TenantInformationService.php +++ b/app/Domains/Core/Tenant/Services/TenantInformationService.php @@ -18,9 +18,12 @@ class TenantInformationService 'footerBackgroundImage', 'storefrontWebsiteType', 'socialMedia', + 'addresses', + 'mainAddress', 'websiteExtras.websiteTypeExtra', 'activeEvent.dates', 'activeEvent.socialMedia', + 'activeEvent.address', ]; /** diff --git a/app/Domains/Ticketing/Event/Resources/EventResource.php b/app/Domains/Ticketing/Event/Resources/EventResource.php index 16f802a8..b5ffc28a 100644 --- a/app/Domains/Ticketing/Event/Resources/EventResource.php +++ b/app/Domains/Ticketing/Event/Resources/EventResource.php @@ -2,6 +2,7 @@ namespace App\Domains\Ticketing\Event\Resources; +use App\Domains\Core\Address\Resources\AddressResource; use App\Domains\Ticketing\Event\Models\Event; use App\Domains\Ticketing\Event\Services\EventDateGroupingService; use Illuminate\Http\Request; @@ -21,6 +22,7 @@ class EventResource extends JsonResource 'title' => $this->title, 'subtitle' => $this->subtitle, 'description' => $this->description, + 'address' => AddressResource::make($this->address), 'location' => $this->location, 'exact_location' => $this->exact_location, 'date_text' => $this->date_text, diff --git a/app/Domains/Ticketing/Event/Resources/PublicEventResource.php b/app/Domains/Ticketing/Event/Resources/PublicEventResource.php index b656676d..4a3d978d 100644 --- a/app/Domains/Ticketing/Event/Resources/PublicEventResource.php +++ b/app/Domains/Ticketing/Event/Resources/PublicEventResource.php @@ -5,6 +5,7 @@ namespace App\Domains\Ticketing\Event\Resources; use App\Domains\Commerce\Catalog\Enums\InventoryPolicy; use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\Variant; +use App\Domains\Core\Address\Resources\AddressResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -19,6 +20,7 @@ class PublicEventResource extends JsonResource 'title' => $this->title, 'subtitle' => $this->subtitle, 'description' => $this->description, + 'address' => AddressResource::make($this->address), 'location' => $this->location, 'exact_location' => $this->exact_location, 'date_text' => $this->date_text, diff --git a/app/Domains/Ticketing/Event/Services/EventAddressService.php b/app/Domains/Ticketing/Event/Services/EventAddressService.php new file mode 100644 index 00000000..a3bdc11b --- /dev/null +++ b/app/Domains/Ticketing/Event/Services/EventAddressService.php @@ -0,0 +1,37 @@ +address; + $coordinates = $coordinatesProvided || $address === null ? $event->exact_location : $address->coordinates(); + $values = [ + 'address_text' => $event->location, + 'latitude' => $coordinates['latitude'] ?? null, + 'longitude' => $coordinates['longitude'] ?? null, + ]; + + if ($address !== null && ! (clone $address)->fill($values)->isDirty()) { + return; + } + + // Editing one event must not change the location of another event or a tenant. + if ($address === null || $address->tenants()->exists() + || $address->events()->where('events.id', '!=', $event->id)->exists()) { + $address = Address::create(['label' => $address?->label ?? 'Ubicación del evento', ...$values]); + $event->address()->associate($address); + $event->save(); + } else { + $address->update($values); + } + + $event->setRelation('address', $address); + } +} diff --git a/app/Domains/Ticketing/Event/Services/EventService.php b/app/Domains/Ticketing/Event/Services/EventService.php index ec2b4369..f7f1fcc7 100644 --- a/app/Domains/Ticketing/Event/Services/EventService.php +++ b/app/Domains/Ticketing/Event/Services/EventService.php @@ -2,18 +2,18 @@ namespace App\Domains\Ticketing\Event\Services; -use App\Domains\Core\Auth\Models\User; use App\Domains\Commerce\Cart\Services\InvalidateEventDateCartsService; use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Catalog\Services\StockReservationService; use App\Domains\Commerce\Catalog\Services\VariantReplacementService; +use App\Domains\Core\Auth\Models\User; +use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Event\Enums\EventDateChangeType; use App\Domains\Ticketing\Event\Events\EventDateRescheduled; use App\Domains\Ticketing\Event\Events\EventDateSuspended; +use App\Domains\Ticketing\Event\Models\Event; use App\Domains\Ticketing\Event\Models\EventDate; use App\Domains\Ticketing\Event\Models\EventDateChange; -use App\Domains\Ticketing\Event\Models\Event; -use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Ticket\Models\Ticket; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -32,11 +32,12 @@ class EventService private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver, private readonly VariantReplacementService $variantReplacementService, private readonly InvalidateEventDateCartsService $invalidateEventDateCarts, + private readonly EventAddressService $eventAddressService, ) {} public function forTenant(Tenant $tenant): Event { - return $tenant->activeEvent->load(['dates.validityTime', 'socialMedia']); + return $tenant->activeEvent->load(['dates.validityTime', 'socialMedia', 'address']); } /** @param array $data */ @@ -49,6 +50,7 @@ class EventService 'location' => $data['location'], ...array_intersect_key($data, ['attachment_id' => true, 'exact_location' => true]), ]); + $this->eventAddressService->syncLocation($event, array_key_exists('exact_location', $data)); $tenant->update([ ...array_intersect_key($data, array_flip([ 'allow_ticket_refund', @@ -64,7 +66,7 @@ class EventService $this->syncLegacyContact($event, $data['contact']); } - return $event->load(['dates.validityTime', 'socialMedia']); + return $event->load(['dates.validityTime', 'socialMedia', 'address']); }); } diff --git a/app/Domains/Ticketing/Event/Services/PublicEventService.php b/app/Domains/Ticketing/Event/Services/PublicEventService.php index 39c38eba..02b87afb 100644 --- a/app/Domains/Ticketing/Event/Services/PublicEventService.php +++ b/app/Domains/Ticketing/Event/Services/PublicEventService.php @@ -23,11 +23,12 @@ class PublicEventService $query->where(function ($query) use ($term): void { $query->where('title', 'like', '%'.$term.'%') ->orWhere('subtitle', 'like', '%'.$term.'%') - ->orWhere('location', 'like', '%'.$term.'%'); + ->orWhereHas('address', fn ($query) => $query->where('address_text', 'like', '%'.$term.'%')) + ->orWhere(fn ($query) => $query->whereNull('address_id')->where('location', 'like', '%'.$term.'%')); }); } - return $query->with('attachment')->orderByDesc('published_at')->orderByDesc('id')->paginate(15, ['*'], 'page', $page); + return $query->with(['attachment', 'address'])->orderByDesc('published_at')->orderByDesc('id')->paginate(15, ['*'], 'page', $page); } public function find(Tenant $tenant, Event $event): Event @@ -38,6 +39,7 @@ class PublicEventService return $event->load([ 'attachment', + 'address', 'dates' => fn ($query) => $query->whereNull('rescheduled_to_event_date_id')->whereNull('suspended_at'), 'socialMedia', 'catalogItems.attachments', diff --git a/app/Domains/Ticketing/Event/documentacion/README.md b/app/Domains/Ticketing/Event/documentacion/README.md index 453bf021..e62aed8c 100644 --- a/app/Domains/Ticketing/Event/documentacion/README.md +++ b/app/Domains/Ticketing/Event/documentacion/README.md @@ -27,6 +27,18 @@ Los datos inválidos, incluidas coordenadas sin texto de dirección, detienen la copia y revierten las inserciones para evitar una migración parcial. Las migraciones deben ejecutarse explícitamente por quien administra la base. +Las respuestas públicas, administrativas y el evento del bootstrap incluyen +`address` con `id`, `label`, `address_text`, `latitude` y `longitude`. Las coordenadas +se serializan como números o `null`. Los campos anteriores de la respuesta conservan +sus valores originales; el frontend lee la ubicación y coordenadas desde `address`. +El guardado administrativo de +`location` y `exact_location` también actualiza `addresses`; si una dirección está +compartida con otro evento o con un tenant, se crea una copia para el evento editado. +El tenant devuelve `addresses` con `is_main` y `main_address`. El storefront usa +todas las direcciones en contacto, la principal en el footer y la propia del evento +en sus tarjetas, cabecera y mapa. Solo se agregan al mapa las direcciones con ambas +coordenadas. + La migración `2026_09_18_000500_restore_events` crea un evento por cada tenant que tenía datos de evento o fechas, y migra las fechas y redes correspondientes. `2026_09_18_000600_associate_active_event_social_media` copia al evento activo las diff --git a/tests/Feature/Address/AddressPresentationTest.php b/tests/Feature/Address/AddressPresentationTest.php new file mode 100644 index 00000000..a133af1b --- /dev/null +++ b/tests/Feature/Address/AddressPresentationTest.php @@ -0,0 +1,163 @@ +id(); + $table->string('codigo')->unique(); + }); + Schema::create('events', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code')->nullable(); + $table->string('title')->nullable(); + $table->string('subtitle')->nullable(); + $table->timestamp('published_at')->nullable(); + $table->foreignId('attachment_id')->nullable(); + $table->string('location')->nullable(); + $table->json('exact_location')->nullable(); + $table->timestamps(); + }); + (require database_path('migrations/2026_09_30_000000_create_addresses_and_tenant_addresses_tables.php'))->up(); + (require database_path('migrations/2026_09_30_000200_add_address_id_to_events.php'))->up(); + } + + public function test_tenant_response_exposes_all_addresses_main_address_and_a_separate_event_address(): void + { + DB::table('tenants')->insert(['codigo' => 'acme']); + $tenant = Tenant::where('codigo', 'acme')->firstOrFail(); + $tenant->forceFill((new Tenant)->getAttributes()); + $principal = Address::create(['label' => 'Principal', 'address_text' => 'Sede 123', 'latitude' => 0, 'longitude' => 0]); + $secondary = Address::create(['label' => 'Sucursal', 'address_text' => 'Otra calle 456']); + $venue = Address::create(['label' => 'Predio', 'address_text' => 'Evento 789', 'latitude' => -32.9431184, 'longitude' => -60.6437991]); + $tenant->addresses()->attach($principal->id, ['is_main' => true]); + $tenant->addresses()->attach($secondary->id); + $tenant->load(['addresses', 'mainAddress']); + foreach (['headerLogo', 'footerLogo', 'favicon', 'headerBackgroundImage', 'footerBackgroundImage', 'storefrontWebsiteType'] as $relation) { + $tenant->setRelation($relation, null); + } + $event = $this->presentedEvent($venue, $tenant); + $tenant->setRelation('activeEvent', $event); + $data = (new TenantResource($tenant))->response()->getData(true)['data']; + + $this->assertCount(2, $data['addresses']); + $this->assertTrue($data['addresses'][0]['is_main']); + $this->assertFalse($data['addresses'][1]['is_main']); + $this->assertSame(0, $data['main_address']['latitude']); + $this->assertNull($data['addresses'][1]['latitude']); + $this->assertSame($venue->id, $data['event']['address']['id']); + $this->assertSame(-32.9431184, $data['event']['address']['latitude']); + $this->assertSame('Evento 789', $data['event']['address']['address_text']); + $this->assertSame('Anterior', $data['event']['location']); + $this->assertArrayNotHasKey('is_main', $data['event']['address']); + } + + public function test_public_and_admin_event_responses_use_the_same_canonical_address(): void + { + $address = Address::create(['label' => 'Predio', 'address_text' => 'Nueva 123', 'latitude' => 0, 'longitude' => -60.6437991]); + $event = $this->presentedEvent($address, new Tenant); + foreach ([EventResource::class, PublicEventResource::class] as $resource) { + $data = (new $resource($event))->response()->getData(true)['data']; + $this->assertSame('Nueva 123', $data['address']['address_text']); + $this->assertSame(0, $data['address']['latitude']); + $this->assertSame(-60.6437991, $data['address']['longitude']); + $this->assertSame('Anterior', $data['location']); + $this->assertSame(20, $data['exact_location']['longitude']); + } + + $event->setRelation('address', null); + $data = (new PublicEventResource($event))->response()->getData(true)['data']; + $this->assertNull($data['address']); + $this->assertSame('Anterior', $data['location']); + $address->update(['latitude' => null, 'longitude' => null]); + $event->setRelation('address', $address); + $data = (new PublicEventResource($event))->response()->getData(true)['data']; + $this->assertNull($data['address']['latitude']); + $this->assertSame(20, $data['exact_location']['longitude']); + } + + public function test_editing_event_location_preserves_canonical_coordinates_unless_explicitly_changed(): void + { + $address = Address::create(['label' => 'Predio', 'address_text' => 'Anterior', 'latitude' => 0, 'longitude' => -60]); + $event = Event::create(['address_id' => $address->id, 'location' => 'Nueva', 'exact_location' => ['latitude' => 10, 'longitude' => 20]]); + DB::transaction(fn () => app(EventAddressService::class)->syncLocation($event)); + $this->assertSame('Nueva', $address->fresh()->address_text); + $this->assertSame('0.0000000', $address->fresh()->latitude); + $this->assertDatabaseCount('addresses', 1); + + $event->exact_location = null; + DB::transaction(fn () => app(EventAddressService::class)->syncLocation($event, true)); + $this->assertNull($address->fresh()->latitude); + $this->assertNull($address->fresh()->longitude); + } + + public function test_editing_a_shared_event_address_does_not_change_tenant_or_other_event_addresses(): void + { + DB::table('tenants')->insert(['codigo' => 'acme']); + $tenant = Tenant::where('codigo', 'acme')->firstOrFail(); + $address = Address::create(['label' => 'Compartida', 'address_text' => 'Original', 'latitude' => -32, 'longitude' => -60]); + $tenant->addresses()->attach($address->id, ['is_main' => true]); + $event = Event::create(['address_id' => $address->id, 'location' => 'Nueva ubicación']); + $other = Event::create(['address_id' => $address->id, 'location' => 'Original']); + + DB::transaction(fn () => app(EventAddressService::class)->syncLocation($event)); + + $this->assertSame('Original', $address->fresh()->address_text); + $this->assertSame($address->id, $other->fresh()->address_id); + $this->assertSame($address->id, $tenant->mainAddress->id); + $this->assertNotSame($address->id, $event->fresh()->address_id); + $this->assertSame('Nueva ubicación', $event->fresh()->address->address_text); + $this->assertSame('-32.0000000', $event->fresh()->address->latitude); + } + + public function test_public_event_search_uses_its_address_and_loads_it_for_the_response(): void + { + DB::table('tenants')->insert(['codigo' => 'acme']); + $tenant = Tenant::where('codigo', 'acme')->firstOrFail(); + $address = Address::create(['label' => 'Predio', 'address_text' => 'Dirección nueva']); + $event = Event::create([ + 'tenant_code' => 'acme', 'title' => 'Festival', 'location' => 'Dirección anterior', + 'address_id' => $address->id, 'published_at' => now()->subMinute(), + ]); + $service = app(PublicEventService::class); + $page = $service->list($tenant, 'nueva', 1); + $this->assertSame($event->id, $page->items()[0]->id); + $this->assertTrue($page->items()[0]->relationLoaded('address')); + $this->assertSame(0, $service->list($tenant, 'anterior', 1)->total()); + } + + public function test_editing_an_unlinked_event_creates_its_address(): void + { + $event = Event::create(['location' => 'Predio 123', 'exact_location' => ['latitude' => -32, 'longitude' => -60]]); + DB::transaction(fn () => app(EventAddressService::class)->syncLocation($event)); + $this->assertSame('Predio 123', $event->fresh()->address->address_text); + $this->assertSame('-60.0000000', $event->fresh()->address->longitude); + $this->assertDatabaseCount('tenant_addresses', 0); + } + + private function presentedEvent(?Address $address, Tenant $tenant): Event + { + $event = new Event(['location' => 'Anterior', 'exact_location' => ['latitude' => 10, 'longitude' => 20]]); + $event->setRelation('address', $address)->setRelation('tenant', $tenant) + ->setRelation('socialMedia', collect())->setRelation('dates', collect())->setRelation('attachment', null); + + return $event; + } +} From 917f2dff095408efe180c527b5f6a8060d288f75 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 30 Sep 2026 11:53:21 -0300 Subject: [PATCH 16/18] feat(phone): implement PhoneNumber and TenantPhoneNumber models with relationships and migration --- .../Core/PhoneNumber/Models/PhoneNumber.php | 30 +++++ .../PhoneNumber/Models/TenantPhoneNumber.php | 31 +++++ .../Resources/PhoneNumberResource.php | 20 ++++ app/Domains/Core/Tenant/Models/Tenant.php | 18 +++ .../Core/Tenant/Resources/TenantResource.php | 3 + .../Services/TenantInformationService.php | 2 + .../Core/Tenant/documentacion/README.md | 3 + ...umbers_and_tenant_phone_numbers_tables.php | 48 ++++++++ ..._copy_tenant_and_contact_phone_numbers.php | 65 +++++++++++ .../Migrations/ContactPhoneNumbersTest.php | 106 ++++++++++++++++++ 10 files changed, 326 insertions(+) create mode 100644 app/Domains/Core/PhoneNumber/Models/PhoneNumber.php create mode 100644 app/Domains/Core/PhoneNumber/Models/TenantPhoneNumber.php create mode 100644 app/Domains/Core/PhoneNumber/Resources/PhoneNumberResource.php create mode 100644 database/migrations/2026_09_30_000400_create_phone_numbers_and_tenant_phone_numbers_tables.php create mode 100644 database/migrations/2026_09_30_000500_copy_tenant_and_contact_phone_numbers.php create mode 100644 tests/Feature/Migrations/ContactPhoneNumbersTest.php diff --git a/app/Domains/Core/PhoneNumber/Models/PhoneNumber.php b/app/Domains/Core/PhoneNumber/Models/PhoneNumber.php new file mode 100644 index 00000000..902dcd9a --- /dev/null +++ b/app/Domains/Core/PhoneNumber/Models/PhoneNumber.php @@ -0,0 +1,30 @@ +number); + } + + public function tenants(): BelongsToMany + { + return $this->belongsToMany(Tenant::class, 'tenant_phone_numbers', 'phone_number_id', 'tenant_codigo', 'id', 'codigo') + ->using(TenantPhoneNumber::class) + ->withPivot(['id', 'is_main']) + ->withTimestamps(); + } +} diff --git a/app/Domains/Core/PhoneNumber/Models/TenantPhoneNumber.php b/app/Domains/Core/PhoneNumber/Models/TenantPhoneNumber.php new file mode 100644 index 00000000..8c9f46c2 --- /dev/null +++ b/app/Domains/Core/PhoneNumber/Models/TenantPhoneNumber.php @@ -0,0 +1,31 @@ + 'boolean']; + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); + } + + public function phoneNumber(): BelongsTo + { + return $this->belongsTo(PhoneNumber::class); + } +} diff --git a/app/Domains/Core/PhoneNumber/Resources/PhoneNumberResource.php b/app/Domains/Core/PhoneNumber/Resources/PhoneNumberResource.php new file mode 100644 index 00000000..d5d3c909 --- /dev/null +++ b/app/Domains/Core/PhoneNumber/Resources/PhoneNumberResource.php @@ -0,0 +1,20 @@ + $this->id, + 'label' => $this->label, + 'number' => $this->number, + 'tel_url' => $this->resource->telUrl(), + 'is_main' => $this->whenPivotLoaded('tenant_phone_numbers', fn () => (bool) $this->pivot->is_main), + ]; + } +} diff --git a/app/Domains/Core/Tenant/Models/Tenant.php b/app/Domains/Core/Tenant/Models/Tenant.php index 9b824a8a..d04549f2 100644 --- a/app/Domains/Core/Tenant/Models/Tenant.php +++ b/app/Domains/Core/Tenant/Models/Tenant.php @@ -8,6 +8,8 @@ use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\Category; use App\Domains\Core\Address\Models\Address; use App\Domains\Core\Address\Models\TenantAddress; +use App\Domains\Core\PhoneNumber\Models\PhoneNumber; +use App\Domains\Core\PhoneNumber\Models\TenantPhoneNumber; use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Menu\Models\Menu; use App\Domains\Core\Menu\Models\TenantMenu; @@ -328,6 +330,22 @@ class Tenant extends Model ->where('tenant_addresses.is_main', true); } + public function phoneNumbers(): BelongsToMany + { + return $this->belongsToMany(PhoneNumber::class, 'tenant_phone_numbers', 'tenant_codigo', 'phone_number_id', 'codigo', 'id') + ->using(TenantPhoneNumber::class) + ->withPivot(['id', 'is_main']) + ->withTimestamps() + ->orderByPivot('is_main', 'desc') + ->orderByPivot('id'); + } + + public function mainPhoneNumber(): HasOneThrough + { + return $this->hasOneThrough(PhoneNumber::class, TenantPhoneNumber::class, 'tenant_codigo', 'id', 'codigo', 'phone_number_id') + ->where('tenant_phone_numbers.is_main', true); + } + public function menues(): BelongsToMany { return $this->belongsToMany( diff --git a/app/Domains/Core/Tenant/Resources/TenantResource.php b/app/Domains/Core/Tenant/Resources/TenantResource.php index f4272ae3..1571930c 100644 --- a/app/Domains/Core/Tenant/Resources/TenantResource.php +++ b/app/Domains/Core/Tenant/Resources/TenantResource.php @@ -4,6 +4,7 @@ namespace App\Domains\Core\Tenant\Resources; use App\Domains\Commerce\Catalog\Models\Category; use App\Domains\Core\Address\Resources\AddressResource; +use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource; use App\Domains\Core\Menu\Models\Menu; use App\Domains\Core\Tenant\Models\StorefrontWebsiteType; use App\Domains\Core\Tenant\Models\Tenant; @@ -45,6 +46,8 @@ class TenantResource extends JsonResource 'addresses' => $this->whenLoaded('addresses', fn () => TenantAddressResource::collection($this->addresses)), 'main_address' => $this->whenLoaded('mainAddress', fn () => AddressResource::make($this->mainAddress)), 'phone' => $this->phone, + 'phone_numbers' => $this->whenLoaded('phoneNumbers', fn () => PhoneNumberResource::collection($this->phoneNumbers)), + 'main_phone_number' => $this->whenLoaded('mainPhoneNumber', fn () => PhoneNumberResource::make($this->mainPhoneNumber)), 'favicon' => $this->favicon?->getTemporaryUrl(1440), 'primary_color' => $this->primary_color, 'secondary_color' => $this->secondary_color, diff --git a/app/Domains/Core/Tenant/Services/TenantInformationService.php b/app/Domains/Core/Tenant/Services/TenantInformationService.php index acdfba63..d7f86f5f 100644 --- a/app/Domains/Core/Tenant/Services/TenantInformationService.php +++ b/app/Domains/Core/Tenant/Services/TenantInformationService.php @@ -20,6 +20,8 @@ class TenantInformationService 'socialMedia', 'addresses', 'mainAddress', + 'phoneNumbers', + 'mainPhoneNumber', 'websiteExtras.websiteTypeExtra', 'activeEvent.dates', 'activeEvent.socialMedia', diff --git a/app/Domains/Core/Tenant/documentacion/README.md b/app/Domains/Core/Tenant/documentacion/README.md index c0050062..b8896209 100644 --- a/app/Domains/Core/Tenant/documentacion/README.md +++ b/app/Domains/Core/Tenant/documentacion/README.md @@ -12,6 +12,7 @@ Es la raíz del modelo multi-tenant. Gestiona organizaciones/sitios, tipos de we - `StorefrontWebsiteTypeExtra`: definición de un extra y su configuración admitida. - `WebsiteExtra`: valor resuelto y estado del extra para un tenant. - `SocialMedia`: catálogo de redes sociales asociables. +- `phoneNumbers` y `mainPhoneNumber`: teléfonos públicos del tenant, definidos en el dominio `Core/PhoneNumber` y vinculados por `tenant_phone_numbers`. Solo un vínculo por tenant puede ser principal. ## Servicios @@ -31,3 +32,5 @@ El par `(dominio, base_path)` es único. Un mismo dominio puede alojar el tenant ## Dependencias y reglas Usa `Attachable` para logos y archivos. Es referenciado por casi todos los dominios para aislamiento. El `codigo` es clave de ruta y clave foránea heredada; no debe sustituirse por `id` sin una migración integral. + +Los resources exponen `phone_numbers` (incluye `is_main`) y `main_phone_number`, con `id`, `label`, `number` y `tel_url`. Contacto muestra la colección y el footer usa el principal. La migración copia primero `tenants.phone` como principal y luego el teléfono de `help.contact`, si es distinto; si no existe teléfono del tenant, el del menú pasa a ser principal. Los duplicados por formato se comparan dentro de cada tenant sin inferir país ni código de área. Los campos anteriores se conservan como datos legacy, pero la tienda consume las relaciones nuevas. No hay endpoints de edición de teléfonos ni cambios del adminapp en esta etapa. diff --git a/database/migrations/2026_09_30_000400_create_phone_numbers_and_tenant_phone_numbers_tables.php b/database/migrations/2026_09_30_000400_create_phone_numbers_and_tenant_phone_numbers_tables.php new file mode 100644 index 00000000..458db5a9 --- /dev/null +++ b/database/migrations/2026_09_30_000400_create_phone_numbers_and_tenant_phone_numbers_tables.php @@ -0,0 +1,48 @@ +getDriverName(); + if (! in_array($driver, ['mysql', 'mariadb', 'sqlite', 'pgsql', 'sqlsrv'], true)) { + throw new RuntimeException("Unsupported phone number database driver: {$driver}"); + } + + Schema::create('phone_numbers', function (Blueprint $table): void { + $table->id(); + $table->string('label'); + $table->string('number'); + $table->timestamps(); + }); + Schema::create('tenant_phone_numbers', function (Blueprint $table) use ($driver): void { + $table->id(); + $table->string('tenant_codigo'); + $table->foreignId('phone_number_id')->constrained('phone_numbers')->cascadeOnDelete(); + $table->boolean('is_main')->default(false); + $table->timestamps(); + $table->foreign('tenant_codigo')->references('codigo')->on('tenants')->cascadeOnDelete(); + $table->unique(['tenant_codigo', 'phone_number_id']); + if (in_array($driver, ['mysql', 'mariadb'], true)) { + $table->string('main_tenant_codigo')->nullable() + ->virtualAs('CASE WHEN is_main = 1 THEN tenant_codigo ELSE NULL END'); + $table->unique('main_tenant_codigo', 'tenant_phones_one_main'); + } + }); + if (! in_array($driver, ['mysql', 'mariadb'], true)) { + $condition = $driver === 'pgsql' ? 'is_main = true' : 'is_main = 1'; + DB::statement("CREATE UNIQUE INDEX tenant_phones_one_main ON tenant_phone_numbers (tenant_codigo) WHERE {$condition}"); + } + } + + public function down(): void + { + Schema::dropIfExists('tenant_phone_numbers'); + Schema::dropIfExists('phone_numbers'); + } +}; diff --git a/database/migrations/2026_09_30_000500_copy_tenant_and_contact_phone_numbers.php b/database/migrations/2026_09_30_000500_copy_tenant_and_contact_phone_numbers.php new file mode 100644 index 00000000..01f18cd6 --- /dev/null +++ b/database/migrations/2026_09_30_000500_copy_tenant_and_contact_phone_numbers.php @@ -0,0 +1,65 @@ +orderBy('id')->chunkById(100, function ($tenants): void { + foreach ($tenants as $tenant) { + $this->copyPhone($tenant->codigo, $tenant->phone, true); + } + }); + DB::table('tenants_menues')->where('menu_code', 'help.contact') + ->whereNotNull('static_content')->orderBy('id')->chunkById(100, function ($menus): void { + foreach ($menus as $menu) { + $content = json_decode($menu->static_content, true, flags: JSON_THROW_ON_ERROR); + if ($content === null) { + continue; + } + if (! is_array($content)) { + throw new RuntimeException("Invalid contact content for tenant {$menu->tenant_code}"); + } + $this->copyPhone($menu->tenant_code, $content['phone'] ?? null, false); + } + }); + }); + } + + private function copyPhone(string $tenantCode, mixed $number, bool $fromTenant): void + { + if ($number === null || (is_string($number) && trim($number) === '')) { + return; + } + if (! is_string($number) || mb_strlen($number) > 255 || ! preg_match('/[0-9]/', $number)) { + throw new RuntimeException("Invalid phone number for tenant {$tenantCode}"); + } + $numbers = DB::table('phone_numbers') + ->join('tenant_phone_numbers', 'phone_numbers.id', '=', 'tenant_phone_numbers.phone_number_id') + ->where('tenant_codigo', $tenantCode)->get(['phone_numbers.number']); + $dialable = preg_replace('/[^0-9]/', '', $number); + foreach ($numbers as $existing) { + if (preg_replace('/[^0-9]/', '', $existing->number) === $dialable) { + return; + } + } + $isMain = ! DB::table('tenant_phone_numbers')->where('tenant_codigo', $tenantCode)->where('is_main', true)->exists(); + $now = now(); + $id = DB::table('phone_numbers')->insertGetId([ + 'label' => $fromTenant ? 'Principal' : 'Contacto', + 'number' => trim($number), 'created_at' => $now, 'updated_at' => $now, + ]); + DB::table('tenant_phone_numbers')->insert([ + 'tenant_codigo' => $tenantCode, 'phone_number_id' => $id, 'is_main' => $isMain, + 'created_at' => $now, 'updated_at' => $now, + ]); + } + + public function down(): void + { + // Preserve copies that may have been edited. The legacy sources were not changed. + } +}; diff --git a/tests/Feature/Migrations/ContactPhoneNumbersTest.php b/tests/Feature/Migrations/ContactPhoneNumbersTest.php new file mode 100644 index 00000000..4068bf48 --- /dev/null +++ b/tests/Feature/Migrations/ContactPhoneNumbersTest.php @@ -0,0 +1,106 @@ +id(); + $table->string('codigo')->unique(); + $table->string('phone')->nullable(); + $table->timestamps(); + }); + Schema::create('tenants_menues', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('menu_code'); + $table->json('static_content')->nullable(); + }); + (require database_path('migrations/2026_09_30_000400_create_phone_numbers_and_tenant_phone_numbers_tables.php'))->up(); + } + + public function test_tenant_phone_wins_and_copy_is_idempotent_and_scoped(): void + { + DB::table('tenants')->insert([ + ['codigo' => 'a', 'phone' => '+54 341 111-1111'], + ['codigo' => 'b', 'phone' => '+54 341 111-1111'], + ['codigo' => 'c', 'phone' => null], + ['codigo' => 'empty', 'phone' => ''], + ]); + $this->contact('a', '+54 (341) 222-2222'); + $this->contact('b', '54 (341) 1111111'); + $this->contact('c', '+54 341 3333333'); + $copy = require database_path('migrations/2026_09_30_000500_copy_tenant_and_contact_phone_numbers.php'); + $copy->up(); + $copy->up(); + $this->assertDatabaseCount('phone_numbers', 4); + $a = Tenant::where('codigo', 'a')->firstOrFail(); + $b = Tenant::where('codigo', 'b')->firstOrFail(); + $this->assertSame('+54 341 111-1111', $a->mainPhoneNumber->number); + $this->assertCount(2, $a->phoneNumbers); + $this->assertCount(1, $b->phoneNumbers); + $this->assertNotSame($a->mainPhoneNumber->id, $b->mainPhoneNumber->id); + $this->assertSame('+54 341 3333333', Tenant::where('codigo', 'c')->firstOrFail()->mainPhoneNumber->number); + $this->assertNull(Tenant::where('codigo', 'empty')->firstOrFail()->mainPhoneNumber); + $this->assertSame('tel:+543411111111', $a->mainPhoneNumber->telUrl()); + $mainResource = PhoneNumberResource::make($a->mainPhoneNumber)->resolve(); + $this->assertSame('+54 341 111-1111', $mainResource['number']); + $this->assertSame('tel:+543411111111', $mainResource['tel_url']); + $resources = PhoneNumberResource::collection($a->phoneNumbers)->resolve(); + $this->assertTrue($resources[0]['is_main']); + $this->assertFalse($resources[1]['is_main']); + $copy->down(); + $this->assertDatabaseCount('phone_numbers', 4); + $this->assertDatabaseHas('tenants', ['codigo' => 'a', 'phone' => '+54 341 111-1111']); + } + + public function test_database_rejects_two_main_numbers(): void + { + DB::table('tenants')->insert(['codigo' => 'a', 'phone' => '+54 341 1111111']); + $this->contact('a', '+54 341 2222222'); + (require database_path('migrations/2026_09_30_000500_copy_tenant_and_contact_phone_numbers.php'))->up(); + $this->expectException(QueryException::class); + DB::table('tenant_phone_numbers')->where('tenant_codigo', 'a')->update(['is_main' => true]); + } + + public function test_invalid_contact_rolls_back_the_whole_copy(): void + { + DB::table('tenants')->insert(['codigo' => 'a', 'phone' => '+54 341 1111111']); + $this->contact('a', ['invalid']); + try { + (require database_path('migrations/2026_09_30_000500_copy_tenant_and_contact_phone_numbers.php'))->up(); + $this->fail('Invalid legacy numbers must not be silently discarded.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString('Invalid phone number', $exception->getMessage()); + $this->assertDatabaseCount('phone_numbers', 0); + } + } + + public function test_a_number_cannot_be_linked_to_a_missing_tenant(): void + { + $id = DB::table('phone_numbers')->insertGetId(['label' => 'Contacto', 'number' => '123456']); + $this->expectException(QueryException::class); + DB::table('tenant_phone_numbers')->insert([ + 'tenant_codigo' => 'missing', 'phone_number_id' => $id, 'is_main' => true, + ]); + } + + private function contact(string $tenant, mixed $phone): void + { + DB::table('tenants_menues')->insert([ + 'tenant_code' => $tenant, 'menu_code' => 'help.contact', + 'static_content' => json_encode(['phone' => $phone], JSON_THROW_ON_ERROR), + ]); + } +} From 77dd0e271573f1cd6fe5e18d5874947ac72b2f38 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 30 Sep 2026 13:29:41 -0300 Subject: [PATCH 17/18] feat(menu): update contact menu to dynamic content type and preserve legacy data for rollback --- .../Core/Tenant/Resources/TenantResource.php | 4 +- ...09_30_000600_make_contact_menu_dynamic.php | 39 +++++++++++ database/seeders/MenuSeeder.php | 67 +------------------ .../Migrations/DynamicContactMenuTest.php | 48 +++++++++++++ tests/Feature/Seeders/MenuSeederTest.php | 51 +------------- .../Tenant/BootstrapTenantControllerTest.php | 2 +- .../Bootstrap/TenantContactResourceTest.php | 54 +++++++++++++++ 7 files changed, 149 insertions(+), 116 deletions(-) create mode 100644 database/migrations/2026_09_30_000600_make_contact_menu_dynamic.php create mode 100644 tests/Feature/Migrations/DynamicContactMenuTest.php create mode 100644 tests/Unit/Bootstrap/TenantContactResourceTest.php diff --git a/app/Domains/Core/Tenant/Resources/TenantResource.php b/app/Domains/Core/Tenant/Resources/TenantResource.php index 1571930c..fbd51c11 100644 --- a/app/Domains/Core/Tenant/Resources/TenantResource.php +++ b/app/Domains/Core/Tenant/Resources/TenantResource.php @@ -4,8 +4,8 @@ namespace App\Domains\Core\Tenant\Resources; use App\Domains\Commerce\Catalog\Models\Category; use App\Domains\Core\Address\Resources\AddressResource; -use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource; use App\Domains\Core\Menu\Models\Menu; +use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource; use App\Domains\Core\Tenant\Models\StorefrontWebsiteType; use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Event\Models\Event; @@ -233,7 +233,7 @@ class TenantResource extends JsonResource 'route' => $menu->route, ]; - if ($menu->pivot?->static_content !== null) { + if ($menu->content_type === Menu::CONTENT_TYPE_STATIC && $menu->pivot?->static_content !== null) { $formatted['static_content'] = $menu->pivot->static_content; } diff --git a/database/migrations/2026_09_30_000600_make_contact_menu_dynamic.php b/database/migrations/2026_09_30_000600_make_contact_menu_dynamic.php new file mode 100644 index 00000000..ed06fda2 --- /dev/null +++ b/database/migrations/2026_09_30_000600_make_contact_menu_dynamic.php @@ -0,0 +1,39 @@ +where('code', 'help.contact')->update([ + 'content_type' => 'dynamic', + 'static_content_schema' => null, + ]); + // Preserve legacy pivot content for rollback; dynamic menus do not expose it. + } + + public function down(): void + { + DB::table('menues')->where('code', 'help.contact')->update([ + 'content_type' => 'static', + 'static_content_schema' => json_encode([ + 'whatsapp' => 'required|array', + 'whatsapp.whatsapp_url' => 'required|url', + 'whatsapp.whatsapp_label' => 'required|string', + 'phone' => 'required|string', + 'locations' => 'required|array|min:1', + 'locations.*.label' => 'required|string', + 'locations.*.addresses' => 'required|array|min:1', + 'locations.*.addresses.*' => 'required|array:label,address,coordinates', + 'locations.*.addresses.*.label' => 'required|string', + 'locations.*.addresses.*.address' => 'required|string', + 'locations.*.addresses.*.coordinates' => 'required|array|size:2', + 'locations.*.addresses.*.coordinates.0' => 'required|numeric|between:-90,90', + 'locations.*.addresses.*.coordinates.1' => 'required|numeric|between:-180,180', + 'map_locations' => 'prohibited', + ], JSON_THROW_ON_ERROR), + ]); + } +}; diff --git a/database/seeders/MenuSeeder.php b/database/seeders/MenuSeeder.php index 95d0eabf..3932eeac 100644 --- a/database/seeders/MenuSeeder.php +++ b/database/seeders/MenuSeeder.php @@ -224,23 +224,8 @@ class MenuSeeder extends Seeder 'code' => 'help.contact', 'label' => 'Contacto', 'parent_menu_code' => 'help', - 'content_type' => Menu::CONTENT_TYPE_STATIC, - 'static_content_schema' => [ - 'whatsapp' => 'required|array', - 'whatsapp.whatsapp_url' => 'required|url', - 'whatsapp.whatsapp_label' => 'required|string', - 'phone' => 'required|string', - 'locations' => 'required|array|min:1', - 'locations.*.label' => 'required|string', - 'locations.*.addresses' => 'required|array|min:1', - 'locations.*.addresses.*' => 'required|array:label,address,coordinates', - 'locations.*.addresses.*.label' => 'required|string', - 'locations.*.addresses.*.address' => 'required|string', - 'locations.*.addresses.*.coordinates' => 'required|array|size:2', - 'locations.*.addresses.*.coordinates.0' => 'required|numeric|between:-90,90', - 'locations.*.addresses.*.coordinates.1' => 'required|numeric|between:-180,180', - 'map_locations' => 'prohibited', - ], + 'content_type' => Menu::CONTENT_TYPE_DYNAMIC, + 'static_content_schema' => null, 'route' => '/ayuda/contacto', ], [ @@ -399,30 +384,6 @@ class MenuSeeder extends Seeder 'is_active' => false, ], ]; - $contactContent = [ - 'whatsapp' => [ - 'whatsapp_url' => 'https://wa.me/543412602222', - 'whatsapp_label' => 'Chatea con nosotros', - ], - 'phone' => '+54 9 (0341) 6658247', - 'locations' => [ - 'rosario' => [ - 'label' => 'Rosario', - 'addresses' => [ - [ - 'label' => 'Gigante de Arroyito', - 'address' => 'Av. Génova 640, Rosario', - 'coordinates' => [-32.913997, -60.674567], - ], - [ - 'label' => 'Telepagos', - 'address' => 'Rioja 1150, piso 12, dpto. 3, Rosario', - 'coordinates' => [-32.946820, -60.639320], - ], - ], - ], - ], - ]; $mutualSmepFrequentlyAskedQuestions = [ [ 'pregunta' => '¿Hay algún límite de compra?', @@ -440,25 +401,6 @@ class MenuSeeder extends Seeder 'is_active' => false, ], ]; - $mutualSmepContactContent = [ - 'whatsapp' => [ - 'whatsapp_url' => 'https://wa.me/5493412474530', - 'whatsapp_label' => 'Chateá con Mutual SMEP', - ], - 'phone' => '+54 9 341 247-4530', - 'locations' => [ - 'rosario' => [ - 'label' => 'Rosario', - 'addresses' => [ - [ - 'label' => 'Mutual SMEP', - 'address' => 'San Lorenzo 1543, Rosario, Santa Fe', - 'coordinates' => [-32.9431184, -60.6437991], - ], - ], - ], - ], - ]; foreach ($tenants as $tenant) { $menuCodes = $allMenus; @@ -511,11 +453,6 @@ class MenuSeeder extends Seeder ? $mutualSmepFrequentlyAskedQuestions : $frequentlyAskedQuestions, ]); - $tenant->menues()->updateExistingPivot('help.contact', [ - 'static_content' => $tenant->codigo === 'mutual_smep' - ? $mutualSmepContactContent - : $contactContent, - ]); } } } diff --git a/tests/Feature/Migrations/DynamicContactMenuTest.php b/tests/Feature/Migrations/DynamicContactMenuTest.php new file mode 100644 index 00000000..b4cada4d --- /dev/null +++ b/tests/Feature/Migrations/DynamicContactMenuTest.php @@ -0,0 +1,48 @@ +string('code')->primary(); + $table->string('content_type'); + $table->json('static_content_schema')->nullable(); + }); + Schema::create('tenants_menues', function (Blueprint $table): void { + $table->string('tenant_code'); + $table->string('menu_code'); + $table->json('static_content')->nullable(); + }); + DB::table('menues')->insert([ + ['code' => 'help.contact', 'content_type' => 'static', 'static_content_schema' => '{}'], + ['code' => 'help.faq', 'content_type' => 'static', 'static_content_schema' => '{}'], + ]); + $legacy = json_encode(['whatsapp' => ['whatsapp_url' => 'https://wa.me/123']], JSON_THROW_ON_ERROR); + DB::table('tenants_menues')->insert([ + 'tenant_code' => 'test', 'menu_code' => 'help.contact', 'static_content' => $legacy, + ]); + $migration = require database_path('migrations/2026_09_30_000600_make_contact_menu_dynamic.php'); + $migration->up(); + $migration->up(); + + $this->assertDatabaseHas('menues', [ + 'code' => 'help.contact', 'content_type' => 'dynamic', 'static_content_schema' => null, + ]); + $this->assertDatabaseHas('menues', ['code' => 'help.faq', 'content_type' => 'static', 'static_content_schema' => '{}']); + $this->assertDatabaseHas('tenants_menues', ['menu_code' => 'help.contact', 'static_content' => $legacy]); + + $migration->down(); + $this->assertDatabaseHas('menues', ['code' => 'help.contact', 'content_type' => 'static']); + $schema = json_decode(DB::table('menues')->where('code', 'help.contact')->value('static_content_schema'), true, flags: JSON_THROW_ON_ERROR); + $this->assertSame('required|url', $schema['whatsapp.whatsapp_url']); + $this->assertDatabaseHas('tenants_menues', ['menu_code' => 'help.contact', 'static_content' => $legacy]); + } +} diff --git a/tests/Feature/Seeders/MenuSeederTest.php b/tests/Feature/Seeders/MenuSeederTest.php index f7ef95ad..6f1d4bc0 100644 --- a/tests/Feature/Seeders/MenuSeederTest.php +++ b/tests/Feature/Seeders/MenuSeederTest.php @@ -338,26 +338,8 @@ class MenuSeederTest extends TestCase ], $faq->static_content_schema); $contact = Menu::query()->where('code', 'help.contact')->firstOrFail(); - $this->assertSame(Menu::CONTENT_TYPE_STATIC, $contact->content_type); - $this->assertSame('required|url', $contact->static_content_schema['whatsapp.whatsapp_url']); - $this->assertSame('required|string', $contact->static_content_schema['phone']); - $this->assertSame( - 'required|array|min:1', - $contact->static_content_schema['locations.*.addresses'] - ); - $this->assertSame( - 'required|array|size:2', - $contact->static_content_schema['locations.*.addresses.*.coordinates'] - ); - $this->assertSame( - 'required|string', - $contact->static_content_schema['locations.*.addresses.*.address'] - ); - $this->assertSame( - 'required|numeric|between:-90,90', - $contact->static_content_schema['locations.*.addresses.*.coordinates.0'] - ); - $this->assertSame('prohibited', $contact->static_content_schema['map_locations']); + $this->assertSame(Menu::CONTENT_TYPE_DYNAMIC, $contact->content_type); + $this->assertNull($contact->static_content_schema); } public function test_it_assigns_help_to_the_supported_tenants_with_faq_content(): void @@ -385,30 +367,6 @@ class MenuSeederTest extends TestCase 'is_active' => false, ], ]; - $expectedContact = [ - 'whatsapp' => [ - 'whatsapp_url' => 'https://wa.me/543412602222', - 'whatsapp_label' => 'Chatea con nosotros', - ], - 'phone' => '+54 9 (0341) 6658247', - 'locations' => [ - 'rosario' => [ - 'label' => 'Rosario', - 'addresses' => [ - [ - 'label' => 'Gigante de Arroyito', - 'address' => 'Av. Génova 640, Rosario', - 'coordinates' => [-32.913997, -60.674567], - ], - [ - 'label' => 'Telepagos', - 'address' => 'Rioja 1150, piso 12, dpto. 3, Rosario', - 'coordinates' => [-32.946820, -60.639320], - ], - ], - ], - ], - ]; foreach ([$sonder, $fiesta] as $tenant) { $helpMenus = $tenant->menues() @@ -422,10 +380,7 @@ class MenuSeederTest extends TestCase $expectedQuestions, $helpMenus->firstWhere('code', 'help.faq')->pivot->static_content ); - $this->assertEquals( - $expectedContact, - $helpMenus->firstWhere('code', 'help.contact')->pivot->static_content - ); + $this->assertNull($helpMenus->firstWhere('code', 'help.contact')->pivot->static_content); } $this->assertFalse( diff --git a/tests/Feature/Tenant/BootstrapTenantControllerTest.php b/tests/Feature/Tenant/BootstrapTenantControllerTest.php index 2cb2d97a..8a4fa4d3 100644 --- a/tests/Feature/Tenant/BootstrapTenantControllerTest.php +++ b/tests/Feature/Tenant/BootstrapTenantControllerTest.php @@ -510,7 +510,7 @@ class BootstrapTenantControllerTest extends TestCase $tenant->menues()->sync([ $parent->code, $staticChild->code => ['static_content' => $staticContent], - $dynamicChild->code, + $dynamicChild->code => ['static_content' => ['legacy' => 'must not be returned']], ]); $userRole->menus()->sync([ $parent->code, diff --git a/tests/Unit/Bootstrap/TenantContactResourceTest.php b/tests/Unit/Bootstrap/TenantContactResourceTest.php new file mode 100644 index 00000000..d2d2b904 --- /dev/null +++ b/tests/Unit/Bootstrap/TenantContactResourceTest.php @@ -0,0 +1,54 @@ + 'test']); + foreach (['favicon', 'headerLogo', 'footerLogo', 'headerBackgroundImage', 'footerBackgroundImage', 'storefrontWebsiteType'] as $relation) { + $tenant->setRelation($relation, null); + } + $contact = new Menu([ + 'code' => 'help.contact', 'label' => 'Contacto', 'content_type' => 'dynamic', 'route' => '/ayuda/contacto', + ]); + $contact->setRelation('pivot', new TenantMenu(['static_content' => ['whatsapp' => 'legacy']])); + $faq = new Menu(['code' => 'help.faq', 'label' => 'FAQ', 'content_type' => 'static']); + $faq->setRelation('pivot', new TenantMenu(['static_content' => ['question' => 'Example']])); + $tenant->setRelation('menues', collect([$contact, $faq])); + $tenantSocial = new SocialMedia(['code' => 'whatsapp', 'name' => 'WhatsApp', 'icon' => 'fa-brands fa-whatsapp']); + $tenantSocial->setRelation('pivot', new Pivot(['url' => 'https://wa.me/111'])); + $eventSocial = new SocialMedia(['code' => 'whatsapp', 'name' => 'WhatsApp', 'icon' => 'fa-brands fa-whatsapp']); + $eventSocial->setRelation('pivot', new Pivot(['url' => 'https://wa.me/222'])); + $tenant->setRelation('socialMedia', collect([$tenantSocial])); + $event = new Event; + $event->setRelation('socialMedia', collect([$eventSocial])); + $event->setRelation('address', null); + $event->setRelation('dates', collect()); + $tenant->setRelation('activeEvent', $event); + + $data = TenantResource::make($tenant)->resolve(request()); + $this->assertSame('dynamic', $data['menues'][0]['content_type']); + $this->assertArrayNotHasKey('static_content', $data['menues'][0]); + $this->assertSame(['question' => 'Example'], $data['menues'][1]['static_content']); + $this->assertSame('https://wa.me/222', $data['social_media'][0]['url']); + + $event->setRelation('socialMedia', collect()); + $data = TenantResource::make($tenant)->resolve(request()); + $this->assertCount(0, $data['social_media']); + + $tenant->setRelation('activeEvent', null); + $data = TenantResource::make($tenant)->resolve(request()); + $this->assertSame('https://wa.me/111', $data['social_media'][0]['url']); + } +} From f3e226130fbecbe25d7f694a096f2d31e210105c Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 30 Sep 2026 13:39:24 -0300 Subject: [PATCH 18/18] feat(phone): split phone number components into separate fields and update related models and resources --- .../Auth/Services/AdminAppContextService.php | 3 + .../Core/PhoneNumber/Models/PhoneNumber.php | 35 ++++++- .../Resources/PhoneNumberResource.php | 4 + ...0_000700_split_phone_number_components.php | 94 +++++++++++++++++++ .../Feature/Auth/AdminAppMeControllerTest.php | 23 +++++ .../Migrations/ContactPhoneNumbersTest.php | 44 +++++++++ 6 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_09_30_000700_split_phone_number_components.php diff --git a/app/Domains/Core/Auth/Services/AdminAppContextService.php b/app/Domains/Core/Auth/Services/AdminAppContextService.php index b62a55c3..56c20ec5 100644 --- a/app/Domains/Core/Auth/Services/AdminAppContextService.php +++ b/app/Domains/Core/Auth/Services/AdminAppContextService.php @@ -11,6 +11,9 @@ class AdminAppContextService { $tenant = $user->tenant() ->with([ + 'socialMedia', + 'addresses', + 'phoneNumbers', 'menues' => fn ($query) => $query ->whereHas( 'roles', diff --git a/app/Domains/Core/PhoneNumber/Models/PhoneNumber.php b/app/Domains/Core/PhoneNumber/Models/PhoneNumber.php index 902dcd9a..9cbb4724 100644 --- a/app/Domains/Core/PhoneNumber/Models/PhoneNumber.php +++ b/app/Domains/Core/PhoneNumber/Models/PhoneNumber.php @@ -8,7 +8,27 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; class PhoneNumber extends Model { - protected $fillable = ['label', 'number']; + protected $attributes = ['country_code' => '+54', 'mobile_prefix' => '9']; + + protected $fillable = ['label', 'country_code', 'mobile_prefix', 'area_code', 'local_number']; + + public function getNumberAttribute(?string $legacyNumber): string + { + if ($this->local_number === null) { + return $legacyNumber ?? ''; + } + + $areaCode = $this->mobile_prefix && $this->area_code + ? '('.$this->area_code.')' + : $this->area_code; + + return implode(' ', array_filter([ + $this->country_code, + $this->mobile_prefix, + $areaCode, + $this->local_number, + ], fn ($part): bool => $part !== null && $part !== '')); + } public static function dialableNumber(string $number): string { @@ -17,7 +37,18 @@ class PhoneNumber extends Model public function telUrl(): string { - return 'tel:'.self::dialableNumber($this->number); + if ($this->country_code === null || $this->local_number === null) { + return 'tel:'.self::dialableNumber($this->number); + } + + $areaCode = $this->area_code ?? ''; + if ($this->country_code === '+54') { + $areaCode = ltrim($areaCode, '0'); + } + + return 'tel:'.self::dialableNumber( + $this->country_code.($this->mobile_prefix ?? '').$areaCode.$this->local_number + ); } public function tenants(): BelongsToMany diff --git a/app/Domains/Core/PhoneNumber/Resources/PhoneNumberResource.php b/app/Domains/Core/PhoneNumber/Resources/PhoneNumberResource.php index d5d3c909..0fc30cd5 100644 --- a/app/Domains/Core/PhoneNumber/Resources/PhoneNumberResource.php +++ b/app/Domains/Core/PhoneNumber/Resources/PhoneNumberResource.php @@ -12,6 +12,10 @@ class PhoneNumberResource extends JsonResource return [ 'id' => $this->id, 'label' => $this->label, + 'country_code' => $this->country_code, + 'mobile_prefix' => $this->mobile_prefix, + 'area_code' => $this->area_code, + 'local_number' => $this->local_number, 'number' => $this->number, 'tel_url' => $this->resource->telUrl(), 'is_main' => $this->whenPivotLoaded('tenant_phone_numbers', fn () => (bool) $this->pivot->is_main), diff --git a/database/migrations/2026_09_30_000700_split_phone_number_components.php b/database/migrations/2026_09_30_000700_split_phone_number_components.php new file mode 100644 index 00000000..25898a3a --- /dev/null +++ b/database/migrations/2026_09_30_000700_split_phone_number_components.php @@ -0,0 +1,94 @@ +string('country_code', 8)->nullable()->default('+54'); + $table->string('mobile_prefix', 4)->nullable()->default('9'); + $table->string('area_code', 8)->nullable(); + $table->string('local_number')->nullable(); + }); + + DB::table('phone_numbers')->orderBy('id')->chunkById(100, function ($numbers): void { + foreach ($numbers as $phone) { + DB::table('phone_numbers')->where('id', $phone->id) + ->update($this->components($phone->number)); + } + }); + + Schema::table('phone_numbers', function (Blueprint $table): void { + $table->dropColumn('number'); + }); + } + + public function down(): void + { + Schema::table('phone_numbers', function (Blueprint $table): void { + $table->string('number')->nullable(); + }); + + DB::table('phone_numbers')->orderBy('id')->chunkById(100, function ($numbers): void { + foreach ($numbers as $phone) { + $parts = array_filter([ + $phone->country_code, + $phone->mobile_prefix, + $phone->area_code, + $phone->local_number, + ], fn ($part): bool => $part !== null && $part !== ''); + DB::table('phone_numbers')->where('id', $phone->id) + ->update(['number' => implode(' ', $parts)]); + } + }); + + Schema::table('phone_numbers', function (Blueprint $table): void { + $table->dropColumn(['country_code', 'mobile_prefix', 'area_code', 'local_number']); + }); + } + + /** @return array{country_code: ?string, mobile_prefix: ?string, area_code: ?string, local_number: string} */ + private function components(string $number): array + { + $number = trim($number); + + if (preg_match('/^(\+?\d{1,3})\s+(?:(9)\s+)?\(?(\d{2,5})\)?[\s-]+([\d\s-]+)$/', $number, $match)) { + return [ + 'country_code' => '+'.ltrim($match[1], '+'), + 'mobile_prefix' => ($match[2] ?? '') ?: null, + 'area_code' => $match[3], + 'local_number' => preg_replace('/\D/', '', $match[4]), + ]; + } + + if (preg_match('/^\(?(0\d{2,5})\)?[\s-]+([\d\s-]+)$/', $number, $match)) { + return [ + 'country_code' => null, + 'mobile_prefix' => null, + 'area_code' => $match[1], + 'local_number' => preg_replace('/\D/', '', $match[2]), + ]; + } + + if (preg_match('/^\+54(9?)(\d+)$/', $number, $match)) { + return [ + 'country_code' => '+54', + 'mobile_prefix' => $match[1] === '9' ? '9' : null, + 'area_code' => null, + 'local_number' => $match[2], + ]; + } + + return [ + 'country_code' => null, + 'mobile_prefix' => null, + 'area_code' => null, + 'local_number' => $number, + ]; + } +}; diff --git a/tests/Feature/Auth/AdminAppMeControllerTest.php b/tests/Feature/Auth/AdminAppMeControllerTest.php index 7bd0282a..54d19a50 100644 --- a/tests/Feature/Auth/AdminAppMeControllerTest.php +++ b/tests/Feature/Auth/AdminAppMeControllerTest.php @@ -2,10 +2,13 @@ namespace Tests\Feature\Auth; +use App\Domains\Core\Address\Models\Address; use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Models\Role; use App\Domains\Core\Menu\Models\Menu; +use App\Domains\Core\PhoneNumber\Models\PhoneNumber; +use App\Domains\Core\Tenant\Models\SocialMedia; use App\Domains\Core\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Sanctum\Sanctum; @@ -46,6 +49,21 @@ class AdminAppMeControllerTest extends TestCase $tenant->menues()->sync([$catalog->code, $staff->code, $storefront->code]); $otherTenant->menues()->sync([$staff->code]); + $instagram = SocialMedia::query()->create([ + 'code' => 'instagram', 'name' => 'Instagram', 'icon' => 'fa-brands fa-instagram', + ]); + $tenant->socialMedia()->attach($instagram->code, ['url' => 'https://instagram.com/acme', 'orden' => 0]); + $address = Address::query()->create(['label' => 'Principal', 'address_text' => 'San Martín 123']); + $tenant->addresses()->attach($address->id, ['is_main' => true]); + $phone = PhoneNumber::query()->create([ + 'label' => 'Principal', + 'country_code' => '+54', + 'mobile_prefix' => '9', + 'area_code' => '0341', + 'local_number' => '6658247', + ]); + $tenant->phoneNumbers()->attach($phone->id, ['is_main' => true]); + $user = User::factory()->create([ 'nombre_apellido' => 'Admin Acme', 'email' => 'admin@acme.test', @@ -59,6 +77,11 @@ class AdminAppMeControllerTest extends TestCase ->assertJsonPath('data.user.id', $user->id) ->assertJsonPath('data.user.email', 'admin@acme.test') ->assertJsonPath('data.tenant.codigo', 'acme') + ->assertJsonPath('data.tenant.social_media.0.url', 'https://instagram.com/acme') + ->assertJsonPath('data.tenant.addresses.0.address_text', 'San Martín 123') + ->assertJsonPath('data.tenant.phone_numbers.0.number', '+54 9 (0341) 6658247') + ->assertJsonPath('data.tenant.phone_numbers.0.area_code', '0341') + ->assertJsonPath('data.tenant.phone_numbers.0.local_number', '6658247') ->assertJsonCount(1, 'data.tenant.menues') ->assertJsonPath('data.tenant.menues.0.code', 'admin.catalog') ->assertJsonPath('data.tenant.menues.0.label', 'Catálogo') diff --git a/tests/Feature/Migrations/ContactPhoneNumbersTest.php b/tests/Feature/Migrations/ContactPhoneNumbersTest.php index 4068bf48..c3d2d92f 100644 --- a/tests/Feature/Migrations/ContactPhoneNumbersTest.php +++ b/tests/Feature/Migrations/ContactPhoneNumbersTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Migrations; +use App\Domains\Core\PhoneNumber\Models\PhoneNumber; use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource; use App\Domains\Core\Tenant\Models\Tenant; use Illuminate\Database\QueryException; @@ -96,6 +97,49 @@ class ContactPhoneNumbersTest extends TestCase ]); } + public function test_it_splits_mobile_and_landline_numbers_and_builds_dialable_urls(): void + { + DB::table('phone_numbers')->insert([ + ['label' => 'Móvil', 'number' => '+54 9 (0341) 6658247'], + ['label' => 'Fijo', 'number' => '+54 341 111-1111'], + ['label' => 'Local', 'number' => '0341 1234567'], + ['label' => 'Móvil sin signo', 'number' => '54 9 (0341) 6658247'], + ]); + + $migration = require database_path('migrations/2026_09_30_000700_split_phone_number_components.php'); + $migration->up(); + + [$mobile, $landline, $local, $mobileWithoutPlus] = PhoneNumber::query()->orderBy('id')->get()->all(); + $this->assertSame('+54', $mobile->country_code); + $this->assertSame('9', $mobile->mobile_prefix); + $this->assertSame('0341', $mobile->area_code); + $this->assertSame('6658247', $mobile->local_number); + $this->assertSame('+54 9 (0341) 6658247', $mobile->number); + $this->assertSame('tel:+5493416658247', $mobile->telUrl()); + $this->assertSame('+54 341 1111111', $landline->number); + $this->assertSame('tel:+543411111111', $landline->telUrl()); + $this->assertSame('0341 1234567', $local->number); + $this->assertSame('tel:03411234567', $local->telUrl()); + $this->assertSame('+54 9 (0341) 6658247', $mobileWithoutPlus->number); + $resource = PhoneNumberResource::make($mobile)->resolve(); + $this->assertSame('9', $resource['mobile_prefix']); + $this->assertSame('0341', $resource['area_code']); + $this->assertSame('tel:+5493416658247', $resource['tel_url']); + + $created = PhoneNumber::query()->create([ + 'label' => 'Nuevo', + 'area_code' => '0341', + 'local_number' => '7777777', + ]); + $this->assertSame('+54', $created->fresh()->country_code); + $this->assertSame('9', $created->fresh()->mobile_prefix); + $this->assertSame('+54 9 (0341) 7777777', $created->number); + $this->assertSame('tel:+5493417777777', $created->telUrl()); + + $migration->down(); + $this->assertDatabaseHas('phone_numbers', ['id' => $mobile->id, 'number' => '+54 9 0341 6658247']); + } + private function contact(string $tenant, mixed $phone): void { DB::table('tenants_menues')->insert([