feat: enhance address handling with coordinates and integrate into event resources
This commit is contained in:
@@ -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<Event, $this> */
|
||||
public function events(): HasMany
|
||||
{
|
||||
|
||||
22
app/Domains/Core/Address/Resources/AddressResource.php
Normal file
22
app/Domains/Core/Address/Resources/AddressResource.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Address\Resources;
|
||||
|
||||
use App\Domains\Core\Address\Models\Address;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin Address */
|
||||
class AddressResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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<Address, TenantAddress, $this> */
|
||||
|
||||
14
app/Domains/Core/Tenant/Resources/TenantAddressResource.php
Normal file
14
app/Domains/Core/Tenant/Resources/TenantAddressResource.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Tenant\Resources;
|
||||
|
||||
use App\Domains\Core\Address\Resources\AddressResource;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TenantAddressResource extends AddressResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [...parent::toArray($request), 'is_main' => (bool) $this->pivot->is_main];
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -18,9 +18,12 @@ class TenantInformationService
|
||||
'footerBackgroundImage',
|
||||
'storefrontWebsiteType',
|
||||
'socialMedia',
|
||||
'addresses',
|
||||
'mainAddress',
|
||||
'websiteExtras.websiteTypeExtra',
|
||||
'activeEvent.dates',
|
||||
'activeEvent.socialMedia',
|
||||
'activeEvent.address',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
37
app/Domains/Ticketing/Event/Services/EventAddressService.php
Normal file
37
app/Domains/Ticketing/Event/Services/EventAddressService.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticketing\Event\Services;
|
||||
|
||||
use App\Domains\Core\Address\Models\Address;
|
||||
use App\Domains\Ticketing\Event\Models\Event;
|
||||
|
||||
class EventAddressService
|
||||
{
|
||||
/** Called inside the event update transaction, after validating the legacy location fields. */
|
||||
public function syncLocation(Event $event, bool $coordinatesProvided = false): void
|
||||
{
|
||||
$address = $event->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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, mixed> $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']);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
163
tests/Feature/Address/AddressPresentationTest.php
Normal file
163
tests/Feature/Address/AddressPresentationTest.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Address;
|
||||
|
||||
use App\Domains\Core\Address\Models\Address;
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use App\Domains\Core\Tenant\Resources\TenantResource;
|
||||
use App\Domains\Ticketing\Event\Models\Event;
|
||||
use App\Domains\Ticketing\Event\Resources\EventResource;
|
||||
use App\Domains\Ticketing\Event\Resources\PublicEventResource;
|
||||
use App\Domains\Ticketing\Event\Services\EventAddressService;
|
||||
use App\Domains\Ticketing\Event\Services\PublicEventService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AddressPresentationTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Schema::create('tenants', function (Blueprint $table): void {
|
||||
$table->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user