'whatsapp', 'instagram_url' => 'instagram', 'facebook_url' => 'facebook', ]; public function __construct( private readonly EffectiveEventDateResolver $effectiveEventDateResolver, ) {} public function forTenant(Tenant $tenant): Tenant { return $tenant->load(['eventDates.validityTime', 'socialMedia']); } /** @param array $data */ public function updateForTenant(Tenant $tenant, array $data): Tenant { return DB::transaction(function () use ($tenant, $data): Tenant { $tenant = Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail(); $tenant->update([ 'event_title' => $data['title'], 'event_location' => $data['location'], ]); if (array_key_exists('social_media', $data)) { $this->syncSocialMedia($tenant, $data['social_media']); } else { $this->syncLegacyContact($tenant, $data['contact']); } return $tenant->load(['eventDates.validityTime', 'socialMedia']); }); } /** @param array{date: string, start_time: string, end_time: string} $data */ public function createDateForTenant(Tenant $tenant, array $data): EventDate { return DB::transaction(function () use ($tenant, $data): EventDate { $attributes = $this->dateAttributes($data); if ($tenant->eventDates()->where($attributes)->exists()) { throw ValidationException::withMessages([ 'date' => ['La fecha y el horario ya existen.'], ]); } return $tenant->eventDates()->create($attributes)->load('validityTime'); }); } /** @param array{date: string} $data */ public function rescheduleDateForTenant(Tenant $tenant, EventDate $eventDate, array $data): EventDate { return DB::transaction(function () use ($tenant, $eventDate, $data): EventDate { $source = $this->lockedDateForTenant($tenant, $eventDate); if ($source->cancelled_at !== null) { throw ValidationException::withMessages([ 'event_date' => ['No se puede reprogramar una fecha cancelada.'], ]); } if ($source->rescheduled_to_event_date_id !== null) { throw ValidationException::withMessages([ 'event_date' => ['La fecha ya fue reprogramada.'], ]); } $destination = $tenant->eventDates() ->whereDate('date', $data['date']) ->lockForUpdate() ->first(); if ($destination === null) { $destination = $tenant->eventDates()->create([ 'date' => $data['date'], 'time_start' => $source->time_start, 'time_end' => $source->time_end, ]); } if ($destination->is($source) || $this->chainContains($destination, $source)) { throw ValidationException::withMessages([ 'date' => ['La reprogramación generaría una referencia circular.'], ]); } if ($this->effectiveEventDateResolver->resolve($destination) === null) { throw ValidationException::withMessages([ 'date' => ['La fecha de destino no es utilizable.'], ]); } $source->update(['rescheduled_to_event_date_id' => $destination->getKey()]); return $source->fresh(['validityTime', 'rescheduledTo.validityTime']); }); } public function cancelDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate { return DB::transaction(function () use ($tenant, $eventDate): EventDate { $date = $this->lockedDateForTenant($tenant, $eventDate); if ($date->rescheduled_to_event_date_id !== null) { throw ValidationException::withMessages([ 'event_date' => ['No se puede cancelar una fecha que ya fue reprogramada.'], ]); } if ($date->cancelled_at !== null) { return $date->load('validityTime'); } $date->update(['cancelled_at' => now()]); $this->disableTicketsWithoutUsableDates($tenant, $date); return $date->fresh('validityTime'); }); } private function lockedDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate { return $tenant->eventDates() ->whereKey($eventDate->getKey()) ->lockForUpdate() ->firstOrFail(); } private function chainContains(EventDate $start, EventDate $expected): bool { $current = $start; $visited = []; while ($current->rescheduled_to_event_date_id !== null) { if ($current->is($expected)) { return true; } if (isset($visited[$current->getKey()])) { return true; } $visited[$current->getKey()] = true; $current = $current->rescheduledTo()->lockForUpdate()->first(); if ($current === null) { return false; } } return $current->is($expected); } private function disableTicketsWithoutUsableDates(Tenant $tenant, EventDate $cancelledDate): void { $affectedDateIds = collect([$cancelledDate->getKey()]); $frontier = $affectedDateIds; while ($frontier->isNotEmpty()) { $predecessors = $tenant->eventDates() ->whereIn('rescheduled_to_event_date_id', $frontier) ->pluck('id') ->diff($affectedDateIds) ->values(); $affectedDateIds = $affectedDateIds->merge($predecessors)->unique()->values(); $frontier = $predecessors; } $variants = Variant::withTrashed() ->where(function ($query) use ($affectedDateIds): void { $query->whereIn('event_date_id', $affectedDateIds) ->orWhereHas('eventDates', fn ($eventDates) => $eventDates ->whereIn('event_dates.id', $affectedDateIds)); }) ->with(['eventDates', 'eventDate']) ->get(); foreach ($variants as $variant) { $hasUsableDate = $variant->selectedEventDates()->contains( fn (EventDate $candidate): bool => $this->effectiveEventDateResolver->resolve($candidate) !== null ); if ($hasUsableDate) { continue; } Ticket::query() ->where('tenant_code', $tenant->codigo) ->where('source_variant_id', $variant->getKey()) ->whereNull('disabled_at') ->whereNull('cancelled_at') ->whereNull('refunded_at') ->lockForUpdate() ->get() ->each(function (Ticket $ticket): void { $ticket->markAsDisabled(); $ticket->save(); }); } } /** * @param array{date: string, start_time: string, end_time: string} $data * @return array{date: string, time_start: string, time_end: string} */ private function dateAttributes(array $data): array { return [ 'date' => $data['date'], 'time_start' => $data['start_time'].':00', 'time_end' => $data['end_time'].':00', ]; } /** @param array $contact */ private function syncLegacyContact(Tenant $tenant, array $contact): void { foreach (self::CONTACT_CODES as $field => $code) { $url = $contact[$field] ?? null; if ($url === null || $url === '') { $tenant->socialMedia()->detach($code); continue; } $tenant->socialMedia()->syncWithoutDetaching([ $code => ['url' => $url], ]); } $tenant->unsetRelation('socialMedia'); } /** @param array $socialMedia */ private function syncSocialMedia(Tenant $tenant, array $socialMedia): void { $associations = []; foreach (array_values($socialMedia) as $index => $item) { $associations[$item['code']] = [ 'url' => $item['url'], 'orden' => $item['orden'] ?? $index, ]; } $tenant->socialMedia()->sync($associations); $tenant->unsetRelation('socialMedia'); } }