PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 All 85 releases
← All changes | app/Services/ReviewReminderService.php +208 -37 3.0.2.8 → 3.0.17 View file →
@@ -13,11 +13,53 @@
13 13 namespace Yatra\Services;
14 14
15 15 class ReviewReminderService
16 16 {
17 + public const CRON_HOOK = 'yatra_send_review_reminder';
18 +
17 19 /**
20 + * Days after the tour ends before the review request goes out.
21 + */
22 + public static function reminderDays(): int
23 + {
24 + $days = SettingsService::getInt('review_reminder_days', 3);
25 +
26 + return (int) apply_filters('yatra_review_reminder_days', $days);
27 + }
28 +
29 + /**
30 + * How stale a trip may be and still be worth asking about.
31 + *
32 + * Measured from the tour's end, not from the booking being marked
33 + * completed — see {@see scheduleReminder()} for why that distinction is the
34 + * whole point of this class.
35 + */
36 + public static function maxTourAgeDays(): int
37 + {
38 + $days = SettingsService::getInt('review_reminder_max_age_days', 14);
39 +
40 + return (int) apply_filters('yatra_review_reminder_max_age_days', $days);
41 + }
42 +
43 + /**
18 44 * Schedule review reminder for a booking
19 - *
45 + *
46 + * The reminder is anchored to the date the trip ended, never to the moment
47 + * the booking happened to be marked completed. Those two used to be the
48 + * same thing — the reminder was simply "now + N days" — which was fine when
49 + * an operator completed a booking the week its tour finished, and wrong in
50 + * every other case. The daily auto-completion sweep can mark a long backlog
51 + * of finished tours complete in one run, and each of those scheduled a
52 + * review request N days out: customers whose trips had ended months earlier
53 + * were all asked to review on the same night, in one batch.
54 + *
55 + * So: the request is due at tour_end + N. If that moment is still ahead,
56 + * it is scheduled for then. If it has already passed the trip is not
57 + * automatically written off — an operator completing a booking a few days
58 + * late, or a site whose cron slept, should still reach the customer — but
59 + * only while the trip is recent enough to be worth asking about; past
60 + * {@see maxTourAgeDays()} nothing is sent at all.
61 + *
20 62 * @param int $bookingId Booking ID
21 63 */
22 64 public static function scheduleReminder(int $bookingId): void
23 65 {
@@ -24,21 +66,40 @@
24 66 // Check if reviews are enabled
25 67 if (!SettingsService::reviewsEnabled()) {
26 68 return;
27 69 }
28 -
29 - $reminder_days = SettingsService::getInt('review_reminder_days', 7);
30 -
70 +
71 + $reminder_days = self::reminderDays();
72 +
31 73 if ($reminder_days <= 0) {
32 74 return;
33 75 }
34 -
35 - // Schedule reminder using WordPress cron
36 - $timestamp = time() + ($reminder_days * DAY_IN_SECONDS);
37 -
38 - if (!wp_next_scheduled('yatra_send_review_reminder', [$bookingId])) {
39 - wp_schedule_single_event($timestamp, 'yatra_send_review_reminder', [$bookingId]);
76 +
77 + $tourEnd = self::tourEndTimestamp($bookingId);
78 + if ($tourEnd === null) {
79 + // No usable travel date: there is nothing to anchor to, and "now"
80 + // is exactly the anchor that caused the mass send.
81 + return;
40 82 }
83 +
84 + $now = time();
85 + $due = $tourEnd + ($reminder_days * DAY_IN_SECONDS);
86 +
87 + if ($due <= $now) {
88 + if (!self::tourIsRecentEnough($tourEnd, $now)) {
89 + return;
90 + }
91 + // Overdue but still fresh: shortly from now rather than instantly,
92 + // so a sweep that completes a run of bookings does not fire them
93 + // all in the same second.
94 + $due = $now + (5 * MINUTE_IN_SECONDS);
95 + }
96 +
97 + $due = self::spreadWithinHour($due, $bookingId);
98 +
99 + if (!wp_next_scheduled(self::CRON_HOOK, [$bookingId])) {
100 + wp_schedule_single_event($due, self::CRON_HOOK, [$bookingId]);
101 + }
41 102 }
42 103
43 104 /**
44 105 * Send review reminder email
@@ -46,52 +107,162 @@
46 107 * @param int $bookingId Booking ID
47 108 */
48 109 public static function sendReminder(int $bookingId): void
49 110 {
111 + // Checked again at fire time, not only when scheduling: events queued
112 + // by an older release carry no window of their own, and an install that
113 + // upgrades with a backlog already in wp_cron would otherwise send it.
114 + $tourEnd = self::tourEndTimestamp($bookingId);
115 + if ($tourEnd === null || !self::tourIsRecentEnough($tourEnd, time())) {
116 + return;
117 + }
118 +
50 119 $bookingRepository = new \Yatra\Repositories\BookingRepository();
51 - $booking = $bookingRepository->find($bookingId);
52 -
53 - if (!$booking || empty($booking->email)) {
120 + $booking = $bookingRepository->findWithTrip($bookingId);
121 +
122 + if (!$booking || empty($booking->contact_email)) {
54 123 return;
55 124 }
56 -
125 +
57 126 // Check if customer has already reviewed
58 - if (self::hasCustomerReviewed($bookingId, $booking->customer_id)) {
127 + if (self::hasCustomerReviewed($bookingId, (int) ($booking->customer_id ?? 0))) {
59 128 return;
60 129 }
61 -
130 +
62 131 // Get trip details
63 132 $tripRepository = new \Yatra\Repositories\TripRepository();
64 - $trip = $tripRepository->find($booking->trip_id);
65 -
133 + $trip = $tripRepository->find((int) ($booking->trip_id ?? 0));
134 +
66 135 if (!$trip) {
67 136 return;
68 137 }
69 -
138 +
70 139 $review_url = get_permalink($trip->id) . '#reviews';
71 - $customerName = trim((string) (($booking->first_name ?? '') . ' ' . ($booking->last_name ?? '')));
72 - if ($customerName === '') {
73 - $customerName = __('there', 'yatra');
140 + $vars = TransactionalEmailTemplateService::variablesFromBooking($booking);
141 + $vars['review_url'] = esc_url($review_url);
142 + $vars['completion_date'] = date_i18n(get_option('date_format'));
143 +
144 + TransactionalEmailTemplateService::sendIfEnabled(
145 + TransactionalEmailTemplateService::TYPE_REVIEW_REQUEST,
146 + (string) $booking->contact_email,
147 + $vars
148 + );
149 + }
150 +
151 + /**
152 + * When the trip ended, as a timestamp, or null when it cannot be told.
153 + *
154 + * Mirrors the COALESCE in
155 + * {@see \Yatra\Repositories\BookingRepository::getConfirmedBookingIdsPastTour()}
156 + * so the sweep that completes a booking and the reminder it schedules agree
157 + * on which date the trip ended. Day trips carry no end_date, hence the
158 + * fallbacks.
159 + */
160 + private static function tourEndTimestamp(int $bookingId): ?int
161 + {
162 + global $wpdb;
163 +
164 + $table = \Yatra\Database\Tables\BookingsTable::getTableName();
165 +
166 + $row = $wpdb->get_row($wpdb->prepare(
167 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from a constant.
168 + "SELECT end_date, start_date, travel_date FROM {$table} WHERE id = %d",
169 + $bookingId
170 + ));
171 +
172 + if (!$row) {
173 + return null;
74 174 }
75 - $tripName = (string) ($trip->title ?? '');
76 175
77 - $subject = sprintf(
78 - /* translators: 1: site name, 2: trip title */
79 - __('⭐ [%1$s] How was %2$s?', 'yatra'),
80 - get_bloginfo('name'),
81 - $tripName !== '' ? $tripName : __('your trip', 'yatra')
82 - );
176 + foreach ([$row->end_date ?? null, $row->start_date ?? null, $row->travel_date ?? null] as $value) {
177 + $value = (string) $value;
178 + if ($value === '' || strpos($value, '0000-00-00') === 0) {
179 + continue;
180 + }
83 181
84 - $body = EmailTemplateDefaults::renderCoreReviewReminderHtml(
85 - $customerName,
86 - $tripName !== '' ? $tripName : __('your trip', 'yatra'),
87 - $review_url
88 - );
182 + // End of that day: a trip finishing today is not "over" at 00:00.
183 + $ts = strtotime($value . ' 23:59:59');
184 + if ($ts !== false) {
185 + return $ts;
186 + }
187 + }
89 188
90 - EmailService::send($booking->email, $subject, $body, ['Content-Type: text/html; charset=UTF-8']);
189 + return null;
91 190 }
92 -
191 +
93 192 /**
193 + * Is the trip still recent enough to ask about?
194 + */
195 + private static function tourIsRecentEnough(int $tourEnd, int $now): bool
196 + {
197 + $maxAge = self::maxTourAgeDays();
198 +
199 + if ($maxAge <= 0) {
200 + return false;
201 + }
202 +
203 + return ($now - $tourEnd) <= ($maxAge * DAY_IN_SECONDS);
204 + }
205 +
206 + /**
207 + * Nudge the due time to a quiet, spread-out moment.
208 + *
209 + * Every trip ending on the same day resolves to the same tour_end + N, so
210 + * without this a busy departure day still lands its whole batch on one
211 + * timestamp — a volume spike from a domain with no history of one, which is
212 + * what mail providers throttle. The offset is derived from the booking id
213 + * rather than randomised so a reschedule of the same booking is stable.
214 + */
215 + private static function spreadWithinHour(int $due, int $bookingId): int
216 + {
217 + return $due + (($bookingId % 60) * MINUTE_IN_SECONDS);
218 + }
219 +
220 + /**
221 + * Drop queued reminders for trips that are now too old to ask about.
222 + *
223 + * Reminders are individual one-off cron events, so changing the rules above
224 + * does nothing for events an earlier release already queued. Without this,
225 + * the sites that hit the mass send would simply receive it again.
226 + *
227 + * @return int number of events unscheduled
228 + */
229 + public static function purgeStaleScheduledReminders(): int
230 + {
231 + $crons = _get_cron_array();
232 + if (!is_array($crons)) {
233 + return 0;
234 + }
235 +
236 + $now = time();
237 + $removed = 0;
238 +
239 + foreach ($crons as $timestamp => $hooks) {
240 + if (!isset($hooks[self::CRON_HOOK]) || !is_array($hooks[self::CRON_HOOK])) {
241 + continue;
242 + }
243 +
244 + foreach ($hooks[self::CRON_HOOK] as $event) {
245 + $args = $event['args'] ?? [];
246 + $bookingId = (int) ($args[0] ?? 0);
247 + if ($bookingId <= 0) {
248 + continue;
249 + }
250 +
251 + $tourEnd = self::tourEndTimestamp($bookingId);
252 + if ($tourEnd !== null && self::tourIsRecentEnough($tourEnd, $now)) {
253 + continue;
254 + }
255 +
256 + wp_unschedule_event((int) $timestamp, self::CRON_HOOK, $args);
257 + $removed++;
258 + }
259 + }
260 +
261 + return $removed;
262 + }
263 +
264 + /**
94 265 * Check if customer has already reviewed the trip
95 266 *
96 267 * @param int $bookingId Booking ID
97 268 * @param int $customerId Customer ID
@@ -117,7 +288,7 @@
117 288 * Initialize review reminder cron
118 289 */
119 290 public static function init(): void
120 291 {
121 - add_action('yatra_send_review_reminder', [self::class, 'sendReminder']);
292 + add_action(self::CRON_HOOK, [self::class, 'sendReminder']);
122 293 }
123 294 }