PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.15
Yatra – Travel Booking & Tour Operator Software v3.0.15
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 2.0.1 2.0.10 All 83 releases
yatra / app / Repositories / SavedTripRepository.php

SavedTripRepository.php in Yatra – Travel Booking & Tour Operator Software 3.0.15, at app/Repositories/SavedTripRepository.php

482 lines 17.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Repositories;
6
7 /**
8 * Saved Trip Repository (Wishlist)
9 *
10 * Handles all database operations for saved trips/wishlist using WordPress user meta.
11 *
12 * @package Yatra\Repositories
13 */
14 class SavedTripRepository extends BaseRepository
15 {
16 /**
17 * User meta key for saved trips
18 */
19 private const META_KEY = 'yatra_saved_trips';
20
21 /**
22 * Acquire a short-lived per-user lock to serialize read-modify-write on
23 * the saved-trips meta. Without this, two concurrent tabs writing to the
24 * same user's wishlist can lose one of the updates.
25 */
26 private function acquireUserLock(int $userId): bool
27 {
28 $key = 'yatra_saved_trips_lock_' . $userId;
29 $deadline = microtime(true) + 1.5;
30 do {
31 // wp_cache_add returns false if the key already exists (atomic check-and-set).
32 if (wp_cache_add($key, 1, 'yatra', 5)) {
33 return true;
34 }
35 usleep(25000); // 25ms
36 } while (microtime(true) < $deadline);
37 return false;
38 }
39
40 private function releaseUserLock(int $userId): void
41 {
42 wp_cache_delete('yatra_saved_trips_lock_' . $userId, 'yatra');
43 }
44
45 /**
46 * Remove a specific trip ID from every user that has it saved. Used by the
47 * trip-deletion cleanup hook so orphan IDs don't accumulate forever.
48 */
49 public function removeTripFromAllUsers(int $tripId): int
50 {
51 if ($tripId <= 0) {
52 return 0;
53 }
54
55 global $wpdb;
56 $rows = $wpdb->get_results(
57 $wpdb->prepare(
58 "SELECT user_id, meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s",
59 self::META_KEY
60 )
61 );
62 if (!$rows) {
63 return 0;
64 }
65
66 $touched = 0;
67 foreach ($rows as $row) {
68 $raw = maybe_unserialize($row->meta_value);
69 $ids = $this->normalizeSavedTripIdsFromMeta(is_array($raw) ? $raw : []);
70 if (!in_array($tripId, $ids, true)) {
71 continue;
72 }
73 $filtered = array_values(array_filter(
74 $ids,
75 static fn (int $id): bool => $id !== $tripId
76 ));
77 update_user_meta((int) $row->user_id, self::META_KEY, $filtered);
78 $touched++;
79 }
80 return $touched;
81 }
82
83 /**
84 * Normalize stored meta to a list of trip IDs (handles legacy rows with trip_id keys).
85 *
86 * @param mixed $savedData
87 * @return list<int>
88 */
89 private function normalizeSavedTripIdsFromMeta($savedData): array
90 {
91 if (!is_array($savedData)) {
92 return [];
93 }
94
95 $ids = [];
96 foreach ($savedData as $item) {
97 if (is_array($item) && isset($item['trip_id'])) {
98 $ids[] = (int) $item['trip_id'];
99 } elseif (is_numeric($item)) {
100 $ids[] = (int) $item;
101 }
102 }
103
104 return array_values(array_unique($ids));
105 }
106
107 /**
108 * Get full table name with prefix (not used, but required by BaseRepository)
109 */
110 protected function getTableName(): string
111 {
112 // Not used since we're using user meta, but required by BaseRepository
113 return $this->wpdb->prefix . 'yatra_saved_trips';
114 }
115
116 /**
117 * Check if trip is saved by user
118 *
119 * @param int $userId User ID
120 * @param int $tripId Trip ID
121 * @return bool
122 */
123 public function isSaved(int $userId, int $tripId): bool
124 {
125 $raw = get_user_meta($userId, self::META_KEY, true);
126
127 return in_array($tripId, $this->normalizeSavedTripIdsFromMeta($raw), true);
128 }
129
130 /**
131 * Save trip for user
132 *
133 * @param int $userId User ID
134 * @param int $tripId Trip ID
135 * @return bool
136 */
137 public function saveTrip(int $userId, int $tripId): bool
138 {
139 $tripId = (int) $tripId;
140 if ($tripId <= 0) {
141 return false;
142 }
143
144 $tripRepository = new TripRepository();
145 $trip = $tripRepository->find($tripId);
146 if (!$trip) {
147 return false;
148 }
149
150 // Serialize concurrent writes per user so two tabs can't lose each
151 // other's updates on the read-modify-write of the meta row. We still
152 // proceed if the lock can't be acquired — better to risk a rare
153 // overwrite than to block the user entirely.
154 $this->acquireUserLock($userId);
155 try {
156 $savedData = get_user_meta($userId, self::META_KEY, true);
157 $savedTripIds = $this->normalizeSavedTripIdsFromMeta(is_array($savedData) ? $savedData : []);
158
159 if (in_array($tripId, $savedTripIds, true)) {
160 return true;
161 }
162
163 $savedTripIds[] = $tripId;
164 $savedTripIds = array_values(array_unique($savedTripIds));
165
166 $updated = update_user_meta($userId, self::META_KEY, $savedTripIds);
167 if ($updated !== false) {
168 return true;
169 }
170
171 // update_user_meta() returns false when the value is unchanged; treat as success if the trip is stored.
172 return $this->isSaved($userId, $tripId);
173 } finally {
174 $this->releaseUserLock($userId);
175 }
176 }
177
178 /**
179 * Remove saved trip
180 *
181 * @param int $userId User ID
182 * @param int $tripId Trip ID
183 * @return bool
184 */
185 public function removeTrip(int $userId, int $tripId): bool
186 {
187 $tripId = (int) $tripId;
188 if ($tripId <= 0) {
189 return false;
190 }
191
192 $this->acquireUserLock($userId);
193 try {
194 $savedData = get_user_meta($userId, self::META_KEY, true);
195 if (!is_array($savedData) || $savedData === []) {
196 return false;
197 }
198
199 $before = $this->normalizeSavedTripIdsFromMeta($savedData);
200 if (!in_array($tripId, $before, true)) {
201 return false;
202 }
203
204 $savedTripIds = array_values(array_filter(
205 $before,
206 static fn (int $id): bool => $id !== $tripId
207 ));
208
209 if (count($savedTripIds) === count($before)) {
210 return false;
211 }
212
213 $updated = update_user_meta($userId, self::META_KEY, $savedTripIds);
214 if ($updated !== false) {
215 return true;
216 }
217
218 return !$this->isSaved($userId, $tripId);
219 } finally {
220 $this->releaseUserLock($userId);
221 }
222 }
223
224 /**
225 * Get user's saved trips (fetches fresh data from database)
226 *
227 * @param int $userId User ID
228 * @param int $limit Limit results (not used with meta, but kept for compatibility)
229 * @return array
230 */
231 public function getUserSavedTrips(int $userId, int $limit = 100): array
232 {
233 // Get saved trip IDs from user meta
234 $savedData = get_user_meta($userId, self::META_KEY, true);
235
236 // Debug: log what we retrieved
237 // Handle empty or invalid data
238 if (empty($savedData)) {
239 return [];
240 }
241
242 // WordPress unserializes automatically, but ensure we have an array
243 if (!is_array($savedData)) {
244 // If it's a string, it might be serialized (shouldn't happen, but handle it)
245 if (is_string($savedData)) {
246 $unserialized = @unserialize($savedData);
247 if ($unserialized !== false && is_array($unserialized)) {
248 $savedData = $unserialized;
249 } else {
250 return [];
251 }
252 } else {
253 return [];
254 }
255 }
256
257 // Handle both old format (array of trip objects) and new format (array of IDs)
258 $savedTripIds = [];
259 foreach ($savedData as $key => $item) {
260 if (is_array($item) && isset($item['trip_id'])) {
261 // Old format: array with trip_id key
262 $savedTripIds[] = (int) $item['trip_id'];
263 } elseif (is_int($item)) {
264 // Direct integer
265 $savedTripIds[] = $item;
266 } elseif (is_numeric($item)) {
267 // Numeric string
268 $savedTripIds[] = (int) $item;
269 } else {
270 }
271 }
272
273 // Remove duplicates and re-index
274 $savedTripIds = array_values(array_unique($savedTripIds));
275
276 if (empty($savedTripIds)) {
277 return [];
278 }
279
280
281
282 // Fetch fresh trip data from database for each saved trip ID
283 $tripRepository = new TripRepository();
284 $validTrips = [];
285
286 foreach ($savedTripIds as $tripId) {
287 if ($tripId <= 0) {
288 continue;
289 }
290
291 $tripObj = $tripRepository->findWithRelations($tripId);
292
293 // Include trip if it exists
294 if (!$tripObj) {
295 continue; // Trip not found, skip it
296 }
297
298 $tripStatus = $tripObj->status ?? '';
299 // Only include published trips (accept both 'publish' and 'published')
300 if (!in_array($tripStatus, ['publish', 'published'], true)) {
301 continue; // Trip not published, skip it
302 }
303
304 // Calculate price using the SAME logic as single-trip.php
305 $hasAvailability = !empty($tripObj->availability_dates) && is_array($tripObj->availability_dates) && count($tripObj->availability_dates) > 0;
306 $pricingType = $tripObj->pricing_type ?? 'regular';
307 $hasTravelerPricing = ($pricingType === 'traveler_based' && !empty($tripObj->price_types));
308
309 // Centralized pricing via TripPricingService (single source of truth)
310 $availDates = $hasAvailability && !empty($tripObj->availability_dates)
311 ? array_map(function($a) { return (object) $a; }, $tripObj->availability_dates)
312 : null;
313 $resolvedPricing = \Yatra\Services\TripPricingService::resolveDisplayPricing($tripObj, $availDates);
314 $originalPrice = $resolvedPricing['original_price'];
315 $displayPrice = $resolvedPricing['current_price'];
316 $hasDiscount = $resolvedPricing['has_discount'];
317 $discountPercent = $resolvedPricing['discount_percentage'];
318 $salePrice = (float) ($tripObj->sale_price ?? 0);
319 $discountedPrice = (float) ($tripObj->discounted_price ?? 0);
320
321 // Get destinations for location
322 $destinations = $tripRepository->getDestinations($tripId);
323 $location = !empty($destinations) ? $destinations[0]->destination_name ?? '' : '';
324
325 // Get difficulty level
326 $difficulty = $tripObj->difficulty_level ?? '';
327
328 // Format duration using helper function
329 $durationDays = !empty($tripObj->duration_days) ? (int) $tripObj->duration_days : null;
330 $durationNights = !empty($tripObj->duration_nights) ? (int) $tripObj->duration_nights : null;
331 // Hour-based day tours show "8 hours" instead of "1 day". 0 for every
332 // existing (day-based) trip, so their wording is unchanged.
333 $durationHours = !empty($tripObj->duration_hours) ? (int) $tripObj->duration_hours : 0;
334 $duration = '';
335 if ($durationHours > 0) {
336 $duration = function_exists('yatra_format_duration')
337 ? yatra_format_duration(0, null, $durationHours)
338 : sprintf(
339 /* translators: %d: number of hours. */
340 _n('%d hour', '%d hours', $durationHours, 'yatra'),
341 $durationHours
342 );
343 } elseif (!empty($durationDays)) {
344 if (function_exists('yatra_format_duration')) {
345 $duration = yatra_format_duration($durationDays, $durationNights);
346 } else {
347 /* translators: %d: number of days. */
348 $duration = sprintf(__('%d Days', 'yatra'), $durationDays);
349 if (!empty($durationNights)) {
350 /* translators: %d: number of nights. */
351 $duration .= ' / ' . sprintf(__('%d Nights', 'yatra'), $durationNights);
352 }
353 }
354 } else {
355 $duration = __('Flexible', 'yatra');
356 }
357
358 // Get highlights (matching listing page logic)
359 $highlights = [];
360
361 // Group size highlights
362 if (!empty($tripObj->max_travelers)) {
363 if ($tripObj->max_travelers <= 2) {
364 $highlights[] = ['text' => __('Private Tour', 'yatra'), 'link' => null];
365 } elseif ($tripObj->max_travelers <= 8) {
366 $highlights[] = ['text' => __('Small Group', 'yatra'), 'link' => null];
367 }
368 }
369
370 // Category highlights (with link)
371 $tripCategories = $tripRepository->getTripCategories($tripId);
372 if (!empty($tripCategories) && is_array($tripCategories)) {
373 $firstCategory = $tripCategories[0];
374 if (!empty($firstCategory->name)) {
375 $catLink = !empty($firstCategory->slug) ? (function_exists('yatra_get_category_permalink') ? yatra_get_category_permalink($firstCategory) : null) : null;
376 $highlights[] = ['text' => $firstCategory->name, 'link' => $catLink];
377 }
378 }
379
380 // Activity highlights (with link)
381 $activities = $tripRepository->getActivities($tripId);
382 if (!empty($activities) && is_array($activities)) {
383 $firstActivity = $activities[0];
384 if (!empty($firstActivity->name)) {
385 $actLink = !empty($firstActivity->slug) ? (function_exists('yatra_get_activity_permalink') ? yatra_get_activity_permalink($firstActivity) : null) : null;
386 $highlights[] = ['text' => $firstActivity->name, 'link' => $actLink];
387 }
388 }
389
390 // Feature highlights
391 if (!empty($tripObj->meals_included) && $tripObj->meals_included === 'all') {
392 $highlights[] = ['text' => __('All Meals Included', 'yatra'), 'link' => null];
393 }
394 if (!empty($tripObj->guide_included) && $tripObj->guide_included) {
395 $highlights[] = ['text' => __('Expert Guide', 'yatra'), 'link' => null];
396 }
397
398 // Limit to 3 highlights
399 $highlights = array_slice($highlights, 0, 3);
400
401 // Get rating and reviews
402 $avgRating = (float) ($tripObj->avg_rating ?? $tripObj->average_rating ?? 0);
403 $reviewsCount = (int) ($tripObj->reviews_count ?? $tripObj->review_count ?? 0);
404
405 // If rating is 0, try to fetch from ReviewRepository
406 if ($avgRating == 0) {
407 if (class_exists('\Yatra\Repositories\ReviewRepository')) {
408 $reviewRepository = new \Yatra\Repositories\ReviewRepository();
409 $avgRating = $reviewRepository->getAverageRating($tripId);
410 $reviewsCount = $reviewRepository->getReviewCount($tripId);
411 }
412 }
413
414 // Get featured image URL
415 $imageUrl = '';
416 if (!empty($tripObj->featured_image)) {
417 $imageUrl = wp_get_attachment_url($tripObj->featured_image);
418 }
419
420 // Get permalink
421 $permalink = '';
422 if (function_exists('yatra_get_trip_permalink')) {
423 $permalink = yatra_get_trip_permalink($tripObj);
424 } else {
425 $tripBase = \Yatra\Services\SettingsService::getTripBase();
426 $permalink = home_url('/' . $tripBase . '/' . ($tripObj->slug ?? ''));
427 }
428
429 // Build trip data array
430 $validTrips[] = [
431 'id' => $tripId,
432 'trip_id' => $tripId,
433 'trip_title' => $tripObj->title ?? '',
434 'trip_slug' => $tripObj->slug ?? '',
435 'trip_image' => $imageUrl,
436 'price' => $displayPrice,
437 'original_price' => $originalPrice > 0 ? $originalPrice : null,
438 'sale_price' => $salePrice > 0 ? $salePrice : null,
439 'discounted_price' => $discountedPrice > 0 ? $discountedPrice : null,
440 'discount_percent' => ($discountPercent > 0 && !$hasTravelerPricing) ? $discountPercent : null,
441 'pricing_type' => $pricingType,
442 'is_traveler_based' => $hasTravelerPricing,
443 'currency' => $tripObj->currency ?? 'USD',
444 'location' => $location,
445 'duration' => $duration,
446 'duration_days' => $durationDays,
447 'difficulty' => $difficulty,
448 'highlights' => $highlights,
449 'rating' => $avgRating,
450 'average_rating' => $avgRating,
451 'reviews' => $reviewsCount, // Frontend expects 'reviews' field
452 'reviews_count' => $reviewsCount,
453 'review_count' => $reviewsCount,
454 'permalink' => $permalink,
455 ];
456 // If trip not found or not published, skip it (don't show in saved trips)
457 }
458
459 // Don't update user meta here - only remove trips when user explicitly removes them
460 // This prevents clearing saved trips if they're temporarily unpublished
461
462 // Apply limit
463 if ($limit > 0 && count($validTrips) > $limit) {
464 $validTrips = array_slice($validTrips, 0, $limit);
465 }
466
467 return $validTrips;
468 }
469
470 /**
471 * Get count of saved trips for user
472 *
473 * @param int $userId User ID
474 * @return int
475 */
476 public function getCount(int $userId): int
477 {
478 return count($this->getUserSavedTrips($userId));
479 }
480 }
481
482