PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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 2.0.11 All 82 releases
← All changes | app/Controllers/AvailabilityController.php +134 -30 3.0.4trunk 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;
@@ -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 (\Exception $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;
@@ -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