From 6588d9f1a8fd0a9f82687545b9773721967576ff Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 30 Sep 2026 10:59:41 -0300 Subject: [PATCH] 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; + } +}