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
yatra / app / Services / ReviewReminderService.php

ReviewReminderService.php in Yatra – Travel Booking & Tour Operator Software 3.0.17, at app/Services/ReviewReminderService.php

295 lines 9.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Review Reminder Service
4 *
5 * Handles sending review reminders to customers
6 *
7 * @package Yatra\Services
8 * @since 3.0.0
9 */
10
11 declare(strict_types=1);
12
13 namespace Yatra\Services;
14
15 class ReviewReminderService
16 {
17 public const CRON_HOOK = 'yatra_send_review_reminder';
18
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 /**
44 * Schedule review reminder for a booking
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 *
62 * @param int $bookingId Booking ID
63 */
64 public static function scheduleReminder(int $bookingId): void
65 {
66 // Check if reviews are enabled
67 if (!SettingsService::reviewsEnabled()) {
68 return;
69 }
70
71 $reminder_days = self::reminderDays();
72
73 if ($reminder_days <= 0) {
74 return;
75 }
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;
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 }
102 }
103
104 /**
105 * Send review reminder email
106 *
107 * @param int $bookingId Booking ID
108 */
109 public static function sendReminder(int $bookingId): void
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
119 $bookingRepository = new \Yatra\Repositories\BookingRepository();
120 $booking = $bookingRepository->findWithTrip($bookingId);
121
122 if (!$booking || empty($booking->contact_email)) {
123 return;
124 }
125
126 // Check if customer has already reviewed
127 if (self::hasCustomerReviewed($bookingId, (int) ($booking->customer_id ?? 0))) {
128 return;
129 }
130
131 // Get trip details
132 $tripRepository = new \Yatra\Repositories\TripRepository();
133 $trip = $tripRepository->find((int) ($booking->trip_id ?? 0));
134
135 if (!$trip) {
136 return;
137 }
138
139 $review_url = get_permalink($trip->id) . '#reviews';
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;
174 }
175
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 }
181
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 }
188
189 return null;
190 }
191
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 /**
265 * Check if customer has already reviewed the trip
266 *
267 * @param int $bookingId Booking ID
268 * @param int $customerId Customer ID
269 * @return bool
270 */
271 private static function hasCustomerReviewed(int $bookingId, int $customerId): bool
272 {
273 global $wpdb;
274
275 $reviewsTable = \Yatra\Database\Tables\ReviewsTable::getTableName();
276
277 $count = $wpdb->get_var($wpdb->prepare(
278 "SELECT COUNT(*) FROM {$reviewsTable}
279 WHERE booking_id = %d OR customer_id = %d",
280 $bookingId,
281 $customerId
282 ));
283
284 return $count > 0;
285 }
286
287 /**
288 * Initialize review reminder cron
289 */
290 public static function init(): void
291 {
292 add_action(self::CRON_HOOK, [self::class, 'sendReminder']);
293 }
294 }
295