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'); + } +}