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/Controllers/AvailabilityController.php +140 -36 3.0.2.6 → 3.0.17 View file →
@@ -36,14 +36,14 @@
36 36 {
37 37 $namespace = 'yatra/v1';
38 38 $base = 'availability';
39 39
40 - // Collection routes
40 + // Collection routes — view cap for reads, edit cap for writes.
41 41 register_rest_route($namespace, '/' . $base, [
42 42 [
43 43 'methods' => \WP_REST_Server::READABLE,
44 44 'callback' => [$this, 'get_items'],
45 - 'permission_callback' => [$this, 'check_permission'],
45 + 'permission_callback' => [$this, 'check_view_permission'],
46 46 'args' => [
47 47 'trip_id' => [
48 48 'required' => true,
49 49 'type' => 'integer',
@@ -82,14 +82,42 @@
82 82 'permission_callback' => [$this, 'check_permission'],
83 83 ],
84 84 ]);
85 85
86 - // Single item routes
86 + // Read-only list of dates a trip's recurring rules generate, for the
87 + // admin calendar. The main /availability list reads only the stored
88 + // availability_dates table, so a trip configured purely with recurring
89 + // rules showed an empty calendar. These are virtual (governed by the
90 + // rule, not individually editable), so the calendar renders them
91 + // read-only — hence a separate endpoint rather than mixing them into
92 + // the paginated, action-bearing /availability list.
93 + register_rest_route($namespace, '/' . $base . '/generated', [
94 + [
95 + 'methods' => \WP_REST_Server::READABLE,
96 + 'callback' => [$this, 'get_generated_dates'],
97 + 'permission_callback' => [$this, 'check_view_permission'],
98 + 'args' => [
99 + 'trip_id' => [
100 + 'required' => true,
101 + 'type' => 'integer',
102 + 'validate_callback' => function ($param) {
103 + return is_numeric($param) && $param > 0;
104 + },
105 + ],
106 + ],
107 + ],
108 + ]);
109 +
110 + // Single item routes — view cap for read, edit cap for the
111 + // mutations. Delete uses edit as well — there's no separate
112 + // "delete availability date" cap in the registry because
113 + // removing a date is functionally part of trip availability
114 + // editing, not a destructive operation in its own right.
87 115 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)', [
88 116 [
89 117 'methods' => \WP_REST_Server::READABLE,
90 118 'callback' => [$this, 'get_item'],
91 - 'permission_callback' => [$this, 'check_permission'],
119 + 'permission_callback' => [$this, 'check_view_permission'],
92 120 ],
93 121 [
94 122 'methods' => \WP_REST_Server::EDITABLE,
95 123 'callback' => [$this, 'update_item'],
@@ -162,32 +190,22 @@
162 190 if (!empty($availabilityIdByDate)) {
163 191 $this->service->updateBookingAvailabilityIds((int) $tripId, $availabilityIdByDate);
164 192 }
165 193
166 - // Aggregate bookings count per availability date for this trip
167 - // Use AvailabilityService to get booking counts
168 - $countsByAvailabilityId = [];
169 - $bookingCounts = $this->service->getBookingCountsByAvailabilityIds(array_column($items, 'id'));
170 -
171 - foreach ($bookingCounts as $row) {
172 - $aid = (int) ($row->availability_id ?? 0);
173 - if ($aid > 0) {
174 - $countsByAvailabilityId[$aid] = (int) ($row->booked_count ?? 0);
175 - }
176 - }
177 -
178 - $data = array_map(function ($item) use ($request, $countsByAvailabilityId) {
194 + $data = array_map(function ($item) use ($request) {
179 195 $prepared = $this->prepare_item_for_response($item, $request);
180 196
181 - $availabilityId = (int) ($prepared['id'] ?? 0);
182 - $bookedCount = 0;
197 + // Booked is derived from the bookings' own (trip, date, time)
198 + // identity, not the fragile availability_id join — see
199 + // AvailabilityService::getBookedCountForSlot. Each row passes its
200 + // own departure_time so multi-departure dates report per slot.
201 + $bookedCount = $this->service->getBookedCountForSlot(
202 + (int) ($prepared['trip_id'] ?? 0),
203 + (string) ($prepared['departure_date'] ?? ''),
204 + !empty($prepared['departure_time']) ? (string) $prepared['departure_time'] : null
205 + );
183 206
184 - if ($availabilityId > 0 && isset($countsByAvailabilityId[$availabilityId])) {
185 - $bookedCount = (int) $countsByAvailabilityId[$availabilityId];
186 - }
187 -
188 207 $seatsTotal = (int) ($prepared['seats_total'] ?? 0);
189 - $seatsReserved = (int) ($prepared['seats_reserved'] ?? 0);
190 208 $available = max(0, $seatsTotal - $bookedCount);
191 209
192 210 $prepared['booked_seats'] = $bookedCount;
193 211 $prepared['total_seats'] = $seatsTotal;
@@ -213,9 +231,9 @@
213 231 'total' => $total,
214 232 'page' => $filters['page'],
215 233 'per_page' => $filters['per_page'],
216 234 ], 200);
217 - } catch (\Exception $e) {
235 + } catch (\Throwable $e) {
218 236 return new WP_Error(
219 237 'availability_fetch_error',
220 238 $e->getMessage(),
221 239 ['status' => 500]
@@ -241,9 +259,9 @@
241 259 'validation_error',
242 260 $e->getMessage(),
243 261 ['status' => 400]
244 262 );
245 - } catch (\Exception $e) {
263 + } catch (\Throwable $e) {
246 264 return new WP_Error(
247 265 'availability_duplicate_error',
248 266 $e->getMessage(),
249 267 ['status' => 500]
@@ -251,8 +269,76 @@
251 269 }
252 270 }
253 271
254 272 /**
273 + * Read-only dates generated by a trip's recurring rules, for the admin
274 + * calendar. Resolves through AvailabilityResolutionService so Booked /
275 + * Available reflect real bookings (same (trip, date, time) count the storefront
276 + * uses), and returns ONLY rule-generated dates — specific availability rows
277 + * already come from the main list, and trip-default (flexible) dates are left
278 + * out so this overlay is scoped to the recurring-rules gap it exists to fill.
279 + */
280 + public function get_generated_dates(WP_REST_Request $request)
281 + {
282 + try {
283 + $tripId = (int) $request->get_param('trip_id');
284 +
285 + // Fall back to sane defaults for missing OR malformed dates rather than
286 + // passing junk into the resolver (a bad date string errored the query).
287 + $normalizeDate = static function ($value, string $fallback): string {
288 + $value = sanitize_text_field((string) $value);
289 + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
290 + $ts = strtotime($value);
291 + if ($ts !== false && date('Y-m-d', $ts) === $value) {
292 + return $value;
293 + }
294 + }
295 + return $fallback;
296 + };
297 + $fromDate = $normalizeDate($request->get_param('from_date'), date('Y-m-d'));
298 + $toDate = $normalizeDate($request->get_param('to_date'), date('Y-m-d', strtotime('+12 months')));
299 +
300 + $resolver = new \Yatra\Services\AvailabilityResolutionService();
301 + $resolved = $resolver->getAllAvailabilityDates($tripId, $fromDate, $toDate);
302 +
303 + $dates = [];
304 + foreach ($resolved as $slot) {
305 + if (($slot->source ?? '') !== 'recurring_rule') {
306 + continue;
307 + }
308 + $total = (int) ($slot->seats_total ?? 0);
309 + $available = (int) ($slot->seats_available ?? 0);
310 + $dates[] = [
311 + 'id' => (string) ($slot->id ?? ''),
312 + 'trip_id' => $tripId,
313 + 'departure_date' => (string) ($slot->departure_date ?? ''),
314 + 'departure_time' => $slot->departure_time ?? null,
315 + 'arrival_date' => $slot->arrival_date ?? ($slot->departure_date ?? ''),
316 + 'arrival_time' => $slot->arrival_time ?? null,
317 + 'total_seats' => $total,
318 + 'seats_total' => $total,
319 + 'available_seats' => $available,
320 + 'seats_available' => $available,
321 + 'booked_seats' => max(0, $total - $available),
322 + 'waitlist_count' => 0,
323 + 'status' => (string) ($slot->status ?? 'available'),
324 + 'is_blocked' => !empty($slot->is_blocked),
325 + 'original_price' => $slot->original_price ?? null,
326 + 'discounted_price' => $slot->discounted_price ?? null,
327 + // Marks this as a read-only, rule-generated entry so the
328 + // calendar shows it without edit/delete affordances.
329 + 'is_virtual' => true,
330 + 'source' => 'rule',
331 + ];
332 + }
333 +
334 + return new WP_REST_Response(['dates' => $dates, 'total' => count($dates)], 200);
335 + } catch (\Throwable $e) {
336 + return new WP_Error('availability_generated_error', $e->getMessage(), ['status' => 500]);
337 + }
338 + }
339 +
340 + /**
255 341 * Get single availability date
256 342 */
257 343 public function get_item(WP_REST_Request $request)
258 344 {
@@ -269,16 +355,18 @@
269 355 }
270 356
271 357 $prepared = $this->prepare_item_for_response($item, $request);
272 358
273 - // Compute live booked seats for this availability_id
359 + // Booked derived from the bookings' (trip, date, time) identity, the
360 + // same way the list does — not the fragile availability_id join.
274 361 if (!empty($prepared['id'])) {
362 + $bookedCount = $this->service->getBookedCountForSlot(
363 + (int) ($prepared['trip_id'] ?? 0),
364 + (string) ($prepared['departure_date'] ?? ''),
365 + !empty($prepared['departure_time']) ? (string) $prepared['departure_time'] : null
366 + );
275 367
276 - // Use AvailabilityService to get booked count
277 - $bookedCount = $this->service->getBookedCountByAvailabilityId((int) $prepared['id']);
278 -
279 368 $seatsTotal = (int) ($prepared['seats_total'] ?? 0);
280 -
281 369 $available = max(0, $seatsTotal - $bookedCount);
282 370
283 371 $prepared['booked_seats'] = $bookedCount;
284 372 $prepared['seats_available'] = $available;
@@ -284,9 +372,9 @@
284 372 $prepared['seats_available'] = $available;
285 373 }
286 374
287 375 return new WP_REST_Response($prepared, 200);
288 - } catch (\Exception $e) {
376 + } catch (\Throwable $e) {
289 377 return new WP_Error(
290 378 'availability_fetch_error',
291 379 $e->getMessage(),
292 380 ['status' => 500]
@@ -317,9 +405,9 @@
317 405 'validation_error',
318 406 $e->getMessage(),
319 407 ['status' => 400]
320 408 );
321 - } catch (\Exception $e) {
409 + } catch (\Throwable $e) {
322 410 return new WP_Error(
323 411 'availability_create_error',
324 412 $e->getMessage(),
325 413 ['status' => 500]
@@ -351,9 +439,9 @@
351 439 'validation_error',
352 440 $e->getMessage(),
353 441 ['status' => 400]
354 442 );
355 - } catch (\Exception $e) {
443 + } catch (\Throwable $e) {
356 444 return new WP_Error(
357 445 'availability_update_error',
358 446 $e->getMessage(),
359 447 ['status' => 500]
@@ -379,9 +467,9 @@
379 467 'validation_error',
380 468 $e->getMessage(),
381 469 ['status' => 400]
382 470 );
383 - } catch (\Exception $e) {
471 + } catch (\Throwable $e) {
384 472 return new WP_Error(
385 473 'availability_delete_error',
386 474 $e->getMessage(),
387 475 ['status' => 500]
@@ -431,10 +519,26 @@
431 519
432 520 /**
433 521 * Check permission
434 522 */
523 + /**
524 + * Write permission — trip-edits cap. Adding, updating, deleting,
525 + * and duplicating availability dates all mutate trip data, so the
526 + * registered `yatra_edit_trips` cap is the right gate. WP admins
527 + * pass via the Team module's admin-fallback filter.
528 + */
435 529 public function check_permission(?WP_REST_Request $request = null): bool
436 530 {
437 - return current_user_can('manage_options');
531 + return current_user_can('yatra_edit_trips');
532 + }
533 +
534 + /**
535 + * Read permission — view-trips cap. Listing availability dates is
536 + * a read-only operation against trip data; Sales Agent / Front
537 + * Desk / Guide / Accountant / Auditor roles all hold this.
538 + */
539 + public function check_view_permission(?WP_REST_Request $request = null): bool
540 + {
541 + return current_user_can('yatra_view_trips');
438 542 }
439 543 }
440 544