feat(event): add address relationship and migration for event locations
This commit is contained in:
@@ -3,8 +3,10 @@
|
|||||||
namespace App\Domains\Core\Address\Models;
|
namespace App\Domains\Core\Address\Models;
|
||||||
|
|
||||||
use App\Domains\Core\Tenant\Models\Tenant;
|
use App\Domains\Core\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticketing\Event\Models\Event;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Address extends Model
|
class Address extends Model
|
||||||
{
|
{
|
||||||
@@ -18,6 +20,12 @@ class Address extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Event, $this> */
|
||||||
|
public function events(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Event::class);
|
||||||
|
}
|
||||||
|
|
||||||
/** @return BelongsToMany<Tenant, $this> */
|
/** @return BelongsToMany<Tenant, $this> */
|
||||||
public function tenants(): BelongsToMany
|
public function tenants(): BelongsToMany
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Ticketing\Event\Models;
|
namespace App\Domains\Ticketing\Event\Models;
|
||||||
|
|
||||||
use App\Domains\Commerce\Catalog\Models\CatalogItem;
|
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\Client\Models\Client;
|
||||||
use App\Domains\Core\Tenant\Models\SocialMedia;
|
use App\Domains\Core\Tenant\Models\SocialMedia;
|
||||||
use App\Domains\Core\Tenant\Models\Tenant;
|
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\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
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
|
class Event extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected function casts(): array
|
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<Tenant, $this> */
|
/** @return BelongsTo<Tenant, $this> */
|
||||||
@@ -37,6 +38,12 @@ class Event extends Model
|
|||||||
return $this->belongsTo(Client::class);
|
return $this->belongsTo(Client::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Address, $this> */
|
||||||
|
public function address(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Address::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function effectiveClient(): Client
|
public function effectiveClient(): Client
|
||||||
{
|
{
|
||||||
return $this->client ?? $this->tenant->client;
|
return $this->client ?? $this->tenant->client;
|
||||||
|
|||||||
@@ -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)
|
`exact_location` guarda opcionalmente un objeto JSON con `latitude` (-90 a 90)
|
||||||
y `longitude` (-180 a 180). `location` sigue siendo la dirección legible.
|
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
|
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.
|
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
|
`2026_09_18_000600_associate_active_event_social_media` copia al evento activo las
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('events', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('address_id')->nullable()->constrained('addresses')->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('events', function (Blueprint $table): void {
|
||||||
|
$table->dropConstrainedForeignId('address_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::transaction(function (): void {
|
||||||
|
DB::table('events')->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.
|
||||||
|
}
|
||||||
|
};
|
||||||
203
tests/Feature/Migrations/EventAddressesTest.php
Normal file
203
tests/Feature/Migrations/EventAddressesTest.php
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Migrations;
|
||||||
|
|
||||||
|
use App\Domains\Core\Address\Models\Address;
|
||||||
|
use App\Domains\Ticketing\Event\Models\Event;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use RuntimeException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class EventAddressesTest 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');
|
||||||
|
$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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user