db = $db ?: new Db(); $this->records = $records ?: new Records(); $this->states = $states ?: new StateService(); } public function lastError(): string { return $this->last_error; } /** * @param mixed[] $data * @param mixed[] $context */ public function reschedule(int $booking_id, array $data, array $context = []): bool { $booking_id = absint($booking_id); $post = $this->bookingPost($booking_id); if (!$post) return false; $context = $this->context($context, $data); $state = $this->states->state($booking_id); if (!$this->canReschedule($booking_id, $state, $context)) return false; $booking = new BookingEntity($booking_id); $old_draft = $booking->getData()->setAvailabilityExcludedBookingId($booking_id); $new_draft = $this->draftFromData($booking, $data); if (!$new_draft) return $this->reject($booking_id, esc_html__('Booking could not be prepared for rescheduling.', 'daytidy'), $context, $state); if ($this->isNoopReschedule($old_draft, $new_draft)) { return $this->reject($booking_id, esc_html__('Please choose a different date, time, or resource before rescheduling.', 'daytidy'), $context, $state); } $explicit_resources = $this->hasExplicitResources($new_draft, $data); $locks = new CapacityLocks($this->db); if (!$locks->acquireMany([$old_draft, $new_draft], 5, $explicit_resources)) { return $this->reject( $booking_id, esc_html__('The selected booking slot is being changed by another request. Please try again.', 'daytidy'), $context, $state ); } try { $errors = $this->validationErrors($new_draft); if (count($errors)) { return $this->reject($booking_id, implode(' ', $errors), $context, $state); } do_action('DayTidy/A__DayTidy/Booking/Rescheduler__reschedule/before', $booking_id, $old_draft, $new_draft, $context); $this->db->query('START TRANSACTION'); try { $this->storeDraft($booking_id, $new_draft); if (!$this->records->replaceWithDraft($booking_id, $new_draft, true)) { throw new Exception(esc_html__('Booking records could not be updated.', 'daytidy')); } $this->reactivateExpiredBooking($booking_id, $state, $context); $history = $this->historyEntry($old_draft, $new_draft, $context); $this->appendHistory($booking_id, $history); $this->db->query('COMMIT'); } catch (Exception $e) { $this->db->query('ROLLBACK'); throw $e; } clean_post_cache($booking_id); do_action('DayTidy/A__DayTidy/Booking/Rescheduler__reschedule/after', $booking_id, $old_draft, $new_draft, $context, $history); do_action('DayTidy/A__DayTidy/Booking/Booking__reschedule/after', $booking_id, $context, $history); $this->last_error = ''; return true; } catch (Exception $e) { return $this->reject($booking_id, $e->getMessage(), $context, $state); } finally { $locks->release(); } } /** * @param mixed[] $data */ public function draftFromData(BookingEntity $booking, array $data): ?Draft { $offering = $booking->offering(); if (!$offering) return null; $draft = $booking->getData(); $draft->setAvailabilityExcludedBookingId($booking->id()); $method = $draft->getBookingMethod(); if ($method === BookingMethods::DATE_TIME_OPTION) { $draft ->setSessions([]) ->setOptionSessions([]) ->setDate(sanitize_text_field((string) ($data['date'] ?? ''))) ->setTime(sanitize_text_field((string) ($data['time'] ?? ''))); } else if ($method === BookingMethods::OPTION_SESSION) { $draft ->setSessions([]) ->setOptionSessions($this->optionSessions($data)); } else { $draft ->setOptionSessions([]) ->setSessions($this->sessions($data)); } $draft->setResources($this->resources($data)); if ($this->hasExplicitResources($draft, $data)) $draft->lockResourceAssignments(false); return $draft; } /** * @param mixed[] $data * @return string[] */ public function previewErrors(int $booking_id, array $data): array { $booking = new BookingEntity($booking_id); $draft = $this->draftFromData($booking, $data); return $draft ? $this->validationErrors($draft) : [esc_html__('Booking could not be prepared for rescheduling.', 'daytidy')]; } /** * @return string[] */ private function validationErrors(Draft $draft): array { $errors = []; if (!count($draft->getAllSessions())) { $errors[] = esc_html__('Please select a new booking date and time.', 'daytidy'); } else if (!$this->selectedSessionsExist($draft)) { $errors[] = esc_html__('Please select a valid booking date and time.', 'daytidy'); } foreach ($this->optionSessionErrors($draft) as $error) $errors[] = $error; foreach ($draft->getOptionErrors() as $error) $errors[] = wp_strip_all_tags((string) $error); foreach ($draft->getResourceConflictErrors() as $error) $errors[] = wp_strip_all_tags((string) $error); return array_values(array_filter(array_unique($errors))); } /** * @param array{booking: string, payment: string} $state * @param mixed[] $context */ private function reactivateExpiredBooking(int $booking_id, array $state, array $context): void { if ($state[StateService::DIMENSION_BOOKING] !== BookingStatus::EXPIRED) return; $context['source'] = 'reschedule'; $context['schedule_reminders'] = false; if (!$this->states->confirm($booking_id, $context)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- The state service error is diagnostic exception text, not rendered HTML. throw new Exception($this->states->lastError() ?: esc_html__('Expired booking could not be reactivated.', 'daytidy')); } $this->records->confirm($booking_id); } /** * @return string[] */ private function optionSessionErrors(Draft $draft): array { if ($draft->getBookingMethod() !== BookingMethods::OPTION_SESSION) return []; $errors = []; $offering = $draft->getOffering(); foreach ($draft->getOptions() as $option_id => $quantity) { $option_id = absint($option_id); if ($option_id <= 0 || absint($quantity) <= 0) continue; if (count($draft->sessionsForOption($option_id))) continue; $option = $offering->getOptions()->get($option_id); $title = $option ? $option->title() : esc_html__('selected option', 'daytidy'); $errors[] = sprintf( // translators: Placeholder values. esc_html__('Please select a new booking date and time for %s.', 'daytidy'), wp_strip_all_tags((string) $title) ); } return $errors; } private function bookingPost(int $booking_id): ?WP_Post { $post = $booking_id ? get_post($booking_id) : null; if (!$post instanceof WP_Post || $post->post_type !== BookingPost::PT) { $this->last_error = esc_html__('Booking was not found.', 'daytidy'); return null; } return $post; } /** * @param array{booking: string, payment: string} $state * @param mixed[] $context */ private function canReschedule(int $booking_id, array $state, array $context): bool { $allowed_statuses = (array) apply_filters( 'DayTidy/F__DayTidy/Booking/Rescheduler__allowed_statuses/statuses', [ BookingStatus::PENDING, BookingStatus::CONFIRMED, BookingStatus::EXPIRED, ], $booking_id, $state, $context ); if (in_array($state[StateService::DIMENSION_BOOKING], $allowed_statuses, true)) return true; return $this->reject( $booking_id, esc_html__('Booking cannot be rescheduled from its current booking status.', 'daytidy'), $context, $state ); } /** * @param mixed[] $context * @param mixed[] $data * @return mixed[] */ private function context(array $context, array $data): array { $context['source'] = sanitize_key((string) ($context['source'] ?? 'admin')); $context['actor'] = sanitize_key((string) ($context['actor'] ?? 'admin')); $context['user_id'] = isset($context['user_id']) ? absint($context['user_id']) : get_current_user_id(); $context['reason'] = sanitize_textarea_field((string) ($data['reason'] ?? $context['reason'] ?? '')); $context['send_notifications'] = !empty($data['send_notifications']); $context['changed_at'] = current_time('Y-m-d H:i:s', true); return $context; } /** * @param mixed[] $data * @return string[] */ private function sessions(array $data): array { $sessions = isset($data['sessions']) && is_array($data['sessions']) ? $data['sessions'] : []; return $this->cleanSessions($sessions); } /** * @param mixed[] $data * @return array */ private function optionSessions(array $data): array { $option_sessions = isset($data['option_sessions']) && is_array($data['option_sessions']) ? $data['option_sessions'] : []; $clean = []; foreach ($option_sessions as $option_id => $sessions) { $option_id = absint($option_id); if ($option_id <= 0) continue; $clean[$option_id] = $this->cleanSessions(is_array($sessions) ? $sessions : []); } return $clean; } /** * @param mixed[] $sessions * @return string[] */ private function cleanSessions(array $sessions): array { $clean = []; foreach ($sessions as $session) { $session = sanitize_text_field((string) $session); if ($session === '' || !strtotime($session)) continue; $clean[] = $session; } return array_values(array_unique($clean)); } /** * @param mixed[] $data * @return array */ private function resources(array $data): array { $resources = isset($data['resources']) && is_array($data['resources']) ? $data['resources'] : []; $clean = []; foreach ($resources as $option_id => $resource_id) { $option_id = absint($option_id); $resource_id = absint($resource_id); if ($option_id > 0 && $resource_id > 0) $clean[$option_id] = $resource_id; } return $clean; } /** * @param mixed[] $data */ private function hasExplicitResources(Draft $draft, array $data): bool { $overrides = $this->resourceOverrides($data); foreach ($this->resources($data) as $option_id => $resource_id) { if (absint($resource_id) <= 0) continue; $option = $draft->getOffering()->getOptions()->get(absint($option_id)); if (!$option || !$option->requiresResource()) continue; if ($option->resourceSelectionMode() === ResourceSelectionModes::AUTO && empty($overrides[$option_id])) continue; return true; } return false; } /** * @param mixed[] $data * @return array */ private function resourceOverrides(array $data): array { $overrides = isset($data['resource_overrides']) && is_array($data['resource_overrides']) ? $data['resource_overrides'] : []; $clean = []; foreach ($overrides as $option_id => $enabled) { $option_id = absint($option_id); if ($option_id <= 0) continue; $clean[$option_id] = !empty($enabled); } return $clean; } private function selectedSessionsExist(Draft $draft): bool { $method = $draft->getBookingMethod(); if ($method === BookingMethods::DATE_TIME_OPTION) { return $this->dateTimeSlotExists($draft); } foreach ($draft->getAllSessions() as $session) { if (!$this->sessionExists($draft, (string) $session)) return false; } return true; } private function dateTimeSlotExists(Draft $draft): bool { $session = (new DraftBuilder())->sessionFromDateTime($draft->getDate(), $draft->getTime(), $draft->getOffering()); if (!$session) return false; return $this->sessionExists($draft, $session); } private function sessionExists(Draft $draft, string $selected): bool { $utc = new DateTimeZone('UTC'); $timezone = $draft->getOffering()->getTimeZone(); try { $selected_datetime = new DateTime($selected, $utc); $local_date = new DateTime($selected_datetime->setTimezone($timezone)->format('Y-m-d'), $timezone); } catch (Exception $e) { return false; } $now = time(); foreach ($draft->getOffering()->getDateSessions($local_date, false) as $available) { if (!$available instanceof Session) continue; if ($available->getEnd()->getTimestamp() <= $now) continue; if ($available->startDateTime(DateTimeFormat::tech(), $utc) === $selected) return true; } return false; } private function isNoopReschedule(Draft $old_draft, Draft $new_draft): bool { return $this->normalizedSchedule($old_draft) === $this->normalizedSchedule($new_draft) && $this->normalizedResources($old_draft) === $this->normalizedResources($new_draft); } /** * @return mixed[] */ private function normalizedSchedule(Draft $draft): array { if ($draft->getBookingMethod() !== BookingMethods::OPTION_SESSION) { $sessions = $draft->getAllSessions(); sort($sessions); return $sessions; } $option_sessions = []; foreach ($draft->getOptionSessions() as $option_id => $sessions) { $option_id = absint($option_id); if ($option_id <= 0) continue; $sessions = array_values(array_filter(array_map('strval', $sessions))); sort($sessions); $option_sessions[$option_id] = $sessions; } ksort($option_sessions); return $option_sessions; } /** * @return array */ private function normalizedResources(Draft $draft): array { $resources = $draft->getResources(); ksort($resources); return $resources; } private function storeDraft(int $booking_id, Draft $draft): void { update_post_meta($booking_id, 'daytidy_booking', (string) $draft); update_post_meta($booking_id, 'daytidy_session', $draft->getFirstSession()); update_post_meta($booking_id, 'daytidy_resources', $draft->getResources()); update_post_meta($booking_id, 'daytidy_capacity_units', $draft->getTotalSeats()); $first_session = $draft->getFirstSession(); if ($first_session) { $this->db->update('posts', [ 'post_date' => get_date_from_gmt($first_session), 'post_date_gmt' => $first_session, 'post_modified' => current_time('mysql'), 'post_modified_gmt' => current_time('mysql', true), ], [ 'ID' => $booking_id, ], [ '%s', '%s', '%s', '%s', ], [ '%d', ]); } } /** * @param mixed[] $context * @return mixed[] */ private function historyEntry(Draft $old_draft, Draft $new_draft, array $context): array { return [ 'old_sessions' => $old_draft->getAllSessions(), 'new_sessions' => $new_draft->getAllSessions(), 'old_resources' => $old_draft->getResources(), 'new_resources' => $new_draft->getResources(), 'user_id' => absint($context['user_id'] ?? 0), 'source' => sanitize_key((string) ($context['source'] ?? 'admin')), 'actor' => sanitize_key((string) ($context['actor'] ?? 'admin')), 'reason' => sanitize_textarea_field((string) ($context['reason'] ?? '')), 'changed_at' => sanitize_text_field((string) ($context['changed_at'] ?? current_time('Y-m-d H:i:s', true))), ]; } /** * @param mixed[] $entry */ private function appendHistory(int $booking_id, array $entry): void { $history = get_post_meta($booking_id, self::META_HISTORY, true); $history = is_array($history) ? $history : []; $history[] = $entry; if (count($history) > self::HISTORY_LIMIT) { $history = array_slice($history, -self::HISTORY_LIMIT); } update_post_meta($booking_id, self::META_HISTORY, $history); } /** * @param mixed[] $context * @param array{booking: string, payment: string} $state */ private function reject(int $booking_id, string $message, array $context, array $state): bool { $this->last_error = $message; update_post_meta($booking_id, StateService::META_LAST_ERROR, $message); do_action( 'DayTidy/A__DayTidy/Booking/Rescheduler__reschedule/rejected', $booking_id, $message, $context, $state ); return false; } }