| @@ -18,8 +18,70 @@ | ||
| 18 | 18 | */ |
| 19 | 19 | private const META_KEY = 'yatra_saved_trips'; |
| 20 | 20 | |
| 21 | 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 | + /** | |
| 22 | 84 | * Normalize stored meta to a list of trip IDs (handles legacy rows with trip_id keys). |
| 23 | 85 | * |
| 24 | 86 | * @param mixed $savedData |
| 25 | 87 | * @return list<int> |
| @@ -78,32 +140,40 @@ | ||
| 78 | 140 | if ($tripId <= 0) { |
| 79 | 141 | return false; |
| 80 | 142 | } |
| 81 | 143 | |
| 82 | - $savedData = get_user_meta($userId, self::META_KEY, true); | |
| 83 | - $savedTripIds = $this->normalizeSavedTripIdsFromMeta(is_array($savedData) ? $savedData : []); | |
| 84 | - | |
| 85 | - if (in_array($tripId, $savedTripIds, true)) { | |
| 86 | - return true; | |
| 87 | - } | |
| 88 | - | |
| 89 | 144 | $tripRepository = new TripRepository(); |
| 90 | 145 | $trip = $tripRepository->find($tripId); |
| 91 | - | |
| 92 | 146 | if (!$trip) { |
| 93 | 147 | return false; |
| 94 | 148 | } |
| 95 | 149 | |
| 96 | - $savedTripIds[] = $tripId; | |
| 97 | - $savedTripIds = array_values(array_unique($savedTripIds)); | |
| 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 : []); | |
| 98 | 158 | |
| 99 | - $updated = update_user_meta($userId, self::META_KEY, $savedTripIds); | |
| 100 | - if ($updated !== false) { | |
| 101 | - return true; | |
| 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); | |
| 102 | 175 | } |
| 103 | - | |
| 104 | - // update_user_meta() returns false when the value is unchanged; treat as success if the trip is stored. | |
| 105 | - return $this->isSaved($userId, $tripId); | |
| 106 | 176 | } |
| 107 | 177 | |
| 108 | 178 | /** |
| 109 | 179 | * Remove saved trip |
| @@ -114,33 +184,42 @@ | ||
| 114 | 184 | */ |
| 115 | 185 | public function removeTrip(int $userId, int $tripId): bool |
| 116 | 186 | { |
| 117 | 187 | $tripId = (int) $tripId; |
| 118 | - if ($tripId <= 0 || !$this->isSaved($userId, $tripId)) { | |
| 188 | + if ($tripId <= 0) { | |
| 119 | 189 | return false; |
| 120 | 190 | } |
| 121 | 191 | |
| 122 | - $savedData = get_user_meta($userId, self::META_KEY, true); | |
| 123 | - if (!is_array($savedData) || $savedData === []) { | |
| 124 | - return false; | |
| 125 | - } | |
| 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 | + } | |
| 126 | 198 | |
| 127 | - $before = $this->normalizeSavedTripIdsFromMeta($savedData); | |
| 128 | - $savedTripIds = array_values(array_filter( | |
| 129 | - $before, | |
| 130 | - static fn (int $id): bool => $id !== $tripId | |
| 131 | - )); | |
| 199 | + $before = $this->normalizeSavedTripIdsFromMeta($savedData); | |
| 200 | + if (!in_array($tripId, $before, true)) { | |
| 201 | + return false; | |
| 202 | + } | |
| 132 | 203 | |
| 133 | - if (count($savedTripIds) === count($before)) { | |
| 134 | - return false; | |
| 135 | - } | |
| 204 | + $savedTripIds = array_values(array_filter( | |
| 205 | + $before, | |
| 206 | + static fn (int $id): bool => $id !== $tripId | |
| 207 | + )); | |
| 136 | 208 | |
| 137 | - $updated = update_user_meta($userId, self::META_KEY, $savedTripIds); | |
| 138 | - if ($updated !== false) { | |
| 139 | - return true; | |
| 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); | |
| 140 | 221 | } |
| 141 | - | |
| 142 | - return !$this->isSaved($userId, $tripId); | |
| 143 | 222 | } |
| 144 | 223 | |
| 145 | 224 | /** |
| 146 | 225 | * Get user's saved trips (fetches fresh data from database) |
| @@ -248,15 +327,28 @@ | ||
| 248 | 327 | |
| 249 | 328 | // Format duration using helper function |
| 250 | 329 | $durationDays = !empty($tripObj->duration_days) ? (int) $tripObj->duration_days : null; |
| 251 | 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; | |
| 252 | 334 | $duration = ''; |
| 253 | - if (!empty($durationDays)) { | |
| 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)) { | |
| 254 | 344 | if (function_exists('yatra_format_duration')) { |
| 255 | 345 | $duration = yatra_format_duration($durationDays, $durationNights); |
| 256 | 346 | } else { |
| 347 | + /* translators: %d: number of days. */ | |
| 257 | 348 | $duration = sprintf(__('%d Days', 'yatra'), $durationDays); |
| 258 | 349 | if (!empty($durationNights)) { |
| 350 | + /* translators: %d: number of nights. */ | |
| 259 | 351 | $duration .= ' / ' . sprintf(__('%d Nights', 'yatra'), $durationNights); |
| 260 | 352 | } |
| 261 | 353 | } |
| 262 | 354 | } else { |