PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
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 2.0.1 All 84 releases
← All changes | app/Controllers/TripAvailabilityController.php +223 -36 3.0.2.9 → 3.0.16 View file →
@@ -36,61 +36,66 @@
36 36 public function register_routes(): void
37 37 {
38 38 $namespace = 'yatra/v1';
39 39
40 - // All departures endpoint (without trip ID)
40 + // All departures endpoint (without trip ID) — view cap.
41 41 register_rest_route($namespace, '/departures', [
42 42 [
43 43 'methods' => \WP_REST_Server::READABLE,
44 44 'callback' => [$this, 'get_all_departures'],
45 - 'permission_callback' => [$this, 'check_permission'],
45 + 'permission_callback' => [$this, 'check_view_permission'],
46 46 ],
47 47 ]);
48 -
48 +
49 49 $base = 'trips/(?P<trip_id>[\d]+)/departures';
50 50
51 - // Departures endpoints
51 + // Departures list + create — view cap for read, manage cap
52 + // for create (a departure is a scheduled trip instance, not
53 + // trip content edit).
52 54 register_rest_route($namespace, '/' . $base, [
53 55 [
54 56 'methods' => \WP_REST_Server::READABLE,
55 57 'callback' => [$this, 'get_departures'],
56 - 'permission_callback' => [$this, 'check_permission'],
58 + 'permission_callback' => [$this, 'check_view_permission'],
57 59 ],
58 60 [
59 61 'methods' => \WP_REST_Server::CREATABLE,
60 62 'callback' => [$this, 'create_departure'],
61 - 'permission_callback' => [$this, 'check_permission'],
63 + 'permission_callback' => [$this, 'check_manage_permission'],
62 64 ],
63 65 ]);
64 66
67 + // Single departure — view / update / delete. Update is a
68 + // manage operation; DELETE is the cancellation cap because
69 + // dropping a departure typically means cancelling it.
65 70 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)', [
66 71 [
67 72 'methods' => \WP_REST_Server::READABLE,
68 73 'callback' => [$this, 'get_departure'],
69 - 'permission_callback' => [$this, 'check_permission'],
74 + 'permission_callback' => [$this, 'check_view_permission'],
70 75 ],
71 76 [
72 77 'methods' => \WP_REST_Server::EDITABLE,
73 78 'callback' => [$this, 'update_departure'],
74 - 'permission_callback' => [$this, 'check_permission'],
79 + 'permission_callback' => [$this, 'check_manage_permission'],
75 80 ],
76 81 [
77 82 'methods' => \WP_REST_Server::DELETABLE,
78 83 'callback' => [$this, 'delete_departure'],
79 - 'permission_callback' => [$this, 'check_permission'],
84 + 'permission_callback' => [$this, 'check_cancel_permission'],
80 85 ],
81 86 ]);
82 87
83 - // Past departures endpoint
88 + // Past departures endpoint — view cap.
84 89 register_rest_route($namespace, '/' . $base . '/past', [
85 90 [
86 91 'methods' => \WP_REST_Server::READABLE,
87 92 'callback' => [$this, 'get_past_departures'],
88 - 'permission_callback' => [$this, 'check_permission'],
93 + 'permission_callback' => [$this, 'check_view_permission'],
89 94 ],
90 95 ]);
91 96
92 - // Available dates endpoint (for frontend)
97 + // Available dates endpoint (for frontend booking widget).
93 98 register_rest_route($namespace, '/trips/(?P<trip_id>[\d]+)/available-dates', [
94 99 [
95 100 'methods' => \WP_REST_Server::READABLE,
96 101 'callback' => [$this, 'get_available_dates'],
@@ -97,20 +102,22 @@
97 102 'permission_callback' => '__return_true', // Public endpoint
98 103 ],
99 104 ]);
100 105
101 - // Recurring rules endpoints
106 + // Recurring rules — these are availability templates on the
107 + // TRIP, not on individual departures. Gated on the trip-edit
108 + // cap (same as the other availability controllers).
102 109 $rulesBase = 'trips/(?P<trip_id>[\d]+)/recurring-rules';
103 110 register_rest_route($namespace, '/' . $rulesBase, [
104 111 [
105 112 'methods' => \WP_REST_Server::READABLE,
106 113 'callback' => [$this, 'get_recurring_rules'],
107 - 'permission_callback' => [$this, 'check_permission'],
114 + 'permission_callback' => [$this, 'check_view_permission'],
108 115 ],
109 116 [
110 117 'methods' => \WP_REST_Server::CREATABLE,
111 118 'callback' => [$this, 'create_recurring_rule'],
112 - 'permission_callback' => [$this, 'check_permission'],
119 + 'permission_callback' => [$this, 'check_trip_edit_permission'],
113 120 ],
114 121 ]);
115 122
116 123 register_rest_route($namespace, '/' . $rulesBase . '/(?P<id>[\d]+)', [
@@ -116,38 +123,75 @@
116 123 register_rest_route($namespace, '/' . $rulesBase . '/(?P<id>[\d]+)', [
117 124 [
118 125 'methods' => \WP_REST_Server::READABLE,
119 126 'callback' => [$this, 'get_recurring_rule'],
120 - 'permission_callback' => [$this, 'check_permission'],
127 + 'permission_callback' => [$this, 'check_view_permission'],
121 128 ],
122 129 [
123 130 'methods' => \WP_REST_Server::EDITABLE,
124 131 'callback' => [$this, 'update_recurring_rule'],
125 - 'permission_callback' => [$this, 'check_permission'],
132 + 'permission_callback' => [$this, 'check_trip_edit_permission'],
126 133 ],
127 134 [
128 135 'methods' => \WP_REST_Server::DELETABLE,
129 136 'callback' => [$this, 'delete_recurring_rule'],
130 - 'permission_callback' => [$this, 'check_permission'],
137 + 'permission_callback' => [$this, 'check_trip_edit_permission'],
131 138 ],
132 139 ]);
133 140
134 - // Preview recurring rule dates
141 + // Preview recurring-rule dates — view cap.
135 142 register_rest_route($namespace, '/' . $rulesBase . '/(?P<id>[\d]+)/preview', [
136 143 [
137 144 'methods' => \WP_REST_Server::READABLE,
138 145 'callback' => [$this, 'preview_recurring_rule'],
139 - 'permission_callback' => [$this, 'check_permission'],
146 + 'permission_callback' => [$this, 'check_view_permission'],
140 147 ],
141 148 ]);
142 149 }
143 150
144 151 /**
145 - * Check permission
152 + * Granular cap checks for every Departure endpoint. The previous
153 + * implementation gated everything on `manage_options` which
154 + * locked Sales Agent / Front Desk / Guide / Accountant / Auditor
155 + * out of the departures REST surface despite the role bundles
156 + * granting them view / manage / cancel caps. WP admins pass via
157 + * the Team module's admin-fallback filter.
146 158 */
159 + public function check_view_permission(?WP_REST_Request $request = null): bool
160 + {
161 + return current_user_can('yatra_view_departures');
162 + }
163 +
164 + public function check_manage_permission(?WP_REST_Request $request = null): bool
165 + {
166 + // Held by Owner / Manager / Guide. Used for create + update.
167 + return current_user_can('yatra_manage_departures');
168 + }
169 +
170 + public function check_cancel_permission(?WP_REST_Request $request = null): bool
171 + {
172 + // Held by Owner / Manager only by default. Cancelling a
173 + // departure is a customer-affecting action (refunds, emails)
174 + // so it gets the stricter cap than ordinary management.
175 + return current_user_can('yatra_cancel_departures');
176 + }
177 +
178 + public function check_trip_edit_permission(?WP_REST_Request $request = null): bool
179 + {
180 + // Recurring rules belong to the parent trip, not to any one
181 + // departure. Their lifecycle matches the trip-edit cap.
182 + return current_user_can('yatra_edit_trips');
183 + }
184 +
185 + /**
186 + * @deprecated Kept for any external code referencing the old
187 + * method. Routes to view — safer default than the old
188 + * `manage_options` shorthand. Admin users still pass via the
189 + * admin-fallback layer.
190 + */
147 191 public function check_permission(?WP_REST_Request $request = null): bool
148 192 {
149 - return current_user_can('manage_options');
193 + return $this->check_view_permission($request);
150 194 }
151 195
152 196 // =========================================================================
153 197 // DEPARTURES ENDPOINTS
@@ -153,8 +197,26 @@
153 197 // DEPARTURES ENDPOINTS
154 198 // =========================================================================
155 199
156 200 /**
201 + * Sanitised pagination for the departure list endpoints.
202 + *
203 + * Returns [page, per_page]. per_page is 0 when the caller did not ask for
204 + * pagination, so the list keeps returning every matching row for
205 + * consumers that never sent it (the previous behaviour); page is always
206 + * >= 1. Only when per_page > 0 is a LIMIT / OFFSET window applied.
207 + *
208 + * @return array{0: int, 1: int}
209 + */
210 + private function paginationParams(WP_REST_Request $request): array
211 + {
212 + $perPage = max(0, (int) $request->get_param('per_page'));
213 + $page = max(1, (int) $request->get_param('page'));
214 +
215 + return [$page, $perPage];
216 + }
217 +
218 + /**
157 219 * GET /trips/{trip_id}/departures
158 220 */
159 221 public function get_departures(WP_REST_Request $request): WP_REST_Response
160 222 {
@@ -159,15 +221,21 @@
159 221 public function get_departures(WP_REST_Request $request): WP_REST_Response
160 222 {
161 223 $tripId = (int) $request->get_param('trip_id');
162 224 $status = $request->get_param('status');
225 + $availability = $request->get_param('availability');
226 + $search = $request->get_param('search');
163 227 $source = $request->get_param('source');
164 228 $dateFrom = $request->get_param('date_from');
165 229 $dateTo = $request->get_param('date_to');
166 230 $includePast = $request->get_param('include_past') !== 'false';
167 -
231 +
168 232 $filters = [];
169 233 if ($status) $filters['status'] = $status;
234 + // Capacity is filtered independently of status (see DepartureRepository::applyAvailabilityClause).
235 + if ($availability && in_array($availability, ['available', 'partial', 'full'], true)) $filters['availability'] = $availability;
236 + // Free-text search on date / notes (see DepartureRepository::applySearchClause).
237 + if (is_string($search) && trim($search) !== '') $filters['search'] = trim($search);
170 238 if ($source) $filters['source'] = $source;
171 239 if ($dateFrom && trim($dateFrom) !== '') $filters['date_from'] = $dateFrom;
172 240 if ($dateTo && trim($dateTo) !== '') $filters['date_to'] = $dateTo;
173 241 $filters['include_past'] = $includePast;
@@ -172,8 +240,17 @@
172 240 if ($dateTo && trim($dateTo) !== '') $filters['date_to'] = $dateTo;
173 241 $filters['include_past'] = $includePast;
174 242
175 243 try {
244 + // Server-side pagination, only when the caller asks for it.
245 + [$page, $perPage] = $this->paginationParams($request);
246 + if ($perPage > 0) {
247 + $filters['per_page'] = $perPage;
248 + $filters['page'] = $page;
249 + }
250 + // True total for the SAME filters, independent of the page window —
251 + // count($departures) was the size of the returned page, not the total.
252 + $total = $this->departureService->countByTripId($tripId, $filters);
176 253 $departures = $this->departureService->getByTripId($tripId, $filters);
177 254
178 255 // Get trip information
179 256 $tripRepository = new \Yatra\Repositories\TripRepository();
@@ -192,14 +269,29 @@
192 269 'success' => true,
193 270 'data' => array_map(function ($d) use ($trip, $bookingDepartureRepo, $travellerRepo, $bookingRepo, $capacityService, $departureRepo) {
194 271 // Sync capacity from availability before returning
195 272 $date = $d->start_date ?: $d->date;
196 - $correctCapacity = $capacityService->getCapacityForDate($d->trip_id, $date);
273 + $correctCapacity = $capacityService->getCapacityForDate($d->trip_id, $date, $d->time ?? null);
197 274 if ($correctCapacity > 0 && $d->max_capacity !== $correctCapacity) {
198 275 $departureRepo->update($d->id, ['max_capacity' => $correctCapacity]);
199 276 $d->max_capacity = $correctCapacity;
200 277 }
201 -
278 +
279 + // Promote a departure that has taken place to 'past' so a
280 + // completed departure is never shown with (or hidden behind) a
281 + // stale 'upcoming'/'full' status — the daily cron may not have
282 + // run. Cancelled/trashed departures are left as-is. This keeps
283 + // the status badge and the tab counts date-accurate.
284 + if (!in_array($d->status, ['cancelled', 'trash', 'past'], true)) {
285 + $checkDate = (!empty($d->end_date) && $d->end_date !== '0000-00-00')
286 + ? $d->end_date
287 + : ((!empty($d->start_date) && $d->start_date !== '0000-00-00') ? $d->start_date : $d->date);
288 + if (!empty($checkDate) && $checkDate < date('Y-m-d')) {
289 + $departureRepo->update($d->id, ['status' => 'past']);
290 + $d->status = 'past';
291 + }
292 + }
293 +
202 294 $departureArray = $d->toArray();
203 295
204 296 // Add trip information
205 297 if ($trip) {
@@ -286,9 +378,12 @@
286 378 // Debug: Log time and revenue values
287 379 return $departureArray;
288 380 }, $departures),
289 381 'meta' => [
290 - 'total' => count($departures),
382 + 'total' => $total,
383 + 'page' => $page,
384 + 'per_page' => $perPage > 0 ? $perPage : $total,
385 + 'total_pages' => $perPage > 0 ? max(1, (int) ceil($total / $perPage)) : 1,
291 386 ],
292 387 ]);
293 388 } catch (\Exception $e) {
294 389 return new WP_REST_Response([
@@ -318,9 +413,9 @@
318 413
319 414 // Sync capacity from availability before returning
320 415 $capacityService = new \Yatra\Services\CapacityService();
321 416 $date = $departure->start_date ?: $departure->date;
322 - $correctCapacity = $capacityService->getCapacityForDate($departure->trip_id, $date);
417 + $correctCapacity = $capacityService->getCapacityForDate($departure->trip_id, $date, $departure->time ?? null);
323 418 if ($correctCapacity > 0 && $departure->max_capacity !== $correctCapacity) {
324 419 $repo->update($departure->id, ['max_capacity' => $correctCapacity]);
325 420 $departure->max_capacity = $correctCapacity;
326 421 }
@@ -546,15 +641,21 @@
546 641 */
547 642 public function get_all_departures(WP_REST_Request $request): WP_REST_Response
548 643 {
549 644 $status = $request->get_param('status');
645 + $availability = $request->get_param('availability');
646 + $search = $request->get_param('search');
550 647 $source = $request->get_param('source');
551 648 $dateFrom = $request->get_param('date_from');
552 649 $dateTo = $request->get_param('date_to');
553 650 $includePast = $request->get_param('include_past') !== 'false';
554 -
651 +
555 652 $filters = [];
556 653 if ($status) $filters['status'] = $status;
654 + // Capacity is filtered independently of status (see DepartureRepository::applyAvailabilityClause).
655 + if ($availability && in_array($availability, ['available', 'partial', 'full'], true)) $filters['availability'] = $availability;
656 + // Free-text search on date / notes (see DepartureRepository::applySearchClause).
657 + if (is_string($search) && trim($search) !== '') $filters['search'] = trim($search);
557 658 if ($source) $filters['source'] = $source;
558 659 if ($dateFrom && trim($dateFrom) !== '') $filters['date_from'] = $dateFrom;
559 660 if ($dateTo && trim($dateTo) !== '') $filters['date_to'] = $dateTo;
560 661 $filters['include_past'] = $includePast;
@@ -559,8 +660,17 @@
559 660 if ($dateTo && trim($dateTo) !== '') $filters['date_to'] = $dateTo;
560 661 $filters['include_past'] = $includePast;
561 662
562 663 try {
664 + // Server-side pagination, only when the caller asks for it.
665 + [$page, $perPage] = $this->paginationParams($request);
666 + if ($perPage > 0) {
667 + $filters['per_page'] = $perPage;
668 + $filters['page'] = $page;
669 + }
670 + // True total for the SAME filters, independent of the page window —
671 + // count($processed) was the size of the returned page, not the total.
672 + $total = $this->departureService->countAllDepartures($filters);
563 673 // Get all departures (no trip filter)
564 674 $departures = $this->departureService->getAllDepartures($filters);
565 675
566 676 // Get repository for additional data
@@ -576,14 +686,48 @@
576 686 // Process each departure to add related data
577 687 $processed = array_map(function ($d) use ($tripRepository, $bookingDepartureRepo, $travellerRepo, $bookingRepo, $capacityService, $departureRepo) {
578 688 // Sync capacity from availability before returning
579 689 $date = $d->start_date ?: $d->date;
580 - $correctCapacity = $capacityService->getCapacityForDate($d->trip_id, $date);
581 - if ($correctCapacity > 0 && $d->max_capacity !== $correctCapacity) {
582 - $departureRepo->update($d->id, ['max_capacity' => $correctCapacity]);
583 - $d->max_capacity = $correctCapacity;
690 + $correctCapacity = $capacityService->getCapacityForDate($d->trip_id, $date, $d->time ?? null);
691 + if ($correctCapacity > 0) {
692 + if ((int) $d->max_capacity !== $correctCapacity) {
693 + $departureRepo->update($d->id, ['max_capacity' => $correctCapacity]);
694 + $d->max_capacity = $correctCapacity;
695 + }
696 + } elseif ((int) $d->max_capacity >= 9999) {
697 + // Normalise a legacy "unlimited"/junk capacity sentinel (e.g.
698 + // 9999/11111) to the canonical unlimited value 0, so it isn't
699 + // shown as a huge literal number here while the dashboard renders
700 + // >= 9999 as 0 — the two disagreeing on capacity and occupancy.
701 + //
702 + // Deliberately heal to 0 (NOT the trip's max_travelers): 0 means
703 + // "unlimited" to both the capacity guard (incrementBookedCount)
704 + // and Departure::calculateStatus(), so healing can never flip an
705 + // already (over-)booked departure to 'full' — which capping to a
706 + // smaller number would, and 'full' departures drop out of the
707 + // dashboard's upcoming view. This keeps the sentinel's original
708 + // "unlimited" meaning while making both surfaces agree.
709 + if ((int) $d->max_capacity !== 0) {
710 + $departureRepo->update($d->id, ['max_capacity' => 0]);
711 + $d->max_capacity = 0;
712 + }
584 713 }
585 -
714 +
715 + // Promote a departure that has taken place to 'past' so a completed
716 + // departure is never shown with (or hidden behind) a stale
717 + // 'upcoming'/'full' status — the daily cron may not have run.
718 + // Cancelled/trashed departures are left as-is. Keeps the status
719 + // badge and the dashboard/tab counts date-accurate.
720 + if (!in_array($d->status, ['cancelled', 'trash', 'past'], true)) {
721 + $checkDate = (!empty($d->end_date) && $d->end_date !== '0000-00-00')
722 + ? $d->end_date
723 + : ((!empty($d->start_date) && $d->start_date !== '0000-00-00') ? $d->start_date : $d->date);
724 + if (!empty($checkDate) && $checkDate < date('Y-m-d')) {
725 + $departureRepo->update($d->id, ['status' => 'past']);
726 + $d->status = 'past';
727 + }
728 + }
729 +
586 730 $departureArray = $d->toArray();
587 731
588 732 // Add trip information
589 733 $trip = $tripRepository->find($d->trip_id);
@@ -670,9 +814,12 @@
670 814 return new WP_REST_Response([
671 815 'success' => true,
672 816 'data' => $processed,
673 817 'meta' => [
674 - 'total' => count($processed),
818 + 'total' => $total,
819 + 'page' => $page,
820 + 'per_page' => $perPage > 0 ? $perPage : $total,
821 + 'total_pages' => $perPage > 0 ? max(1, (int) ceil($total / $perPage)) : 1,
675 822 ],
676 823 ]);
677 824 } catch (\Exception $e) {
678 825 return new WP_REST_Response([
@@ -713,13 +860,53 @@
713 860 public function get_available_dates(WP_REST_Request $request): WP_REST_Response
714 861 {
715 862 $tripId = (int) $request->get_param('trip_id');
716 863 $fromDate = $request->get_param('from_date') ?: date('Y-m-d');
717 - $toDate = $request->get_param('to_date') ?: date('Y-m-d', strtotime('+12 months'));
864 + // An explicit to_date always wins; only the default follows the
865 + // configurable booking horizon (12 months unless changed). The default
866 + // is counted from TODAY — not from from_date — exactly as before, so a
867 + // client that sends only from_date gets the same window it always did.
868 + $toDate = $request->get_param('to_date') ?: yatra_get_availability_horizon_date();
718 869
719 870 try {
720 871 $dates = $this->departureService->getAvailableDates($tripId, $fromDate, $toDate);
721 -
872 +
873 + // Attach the departure times each date actually runs. The list above is
874 + // keyed by date and reports `time => null` for rule-generated dates, so a
875 + // trip running several departures a day looked like a single slot — and
876 + // an operator booking it from the admin had no way to say which departure
877 + // the booking was for. Capacity is tracked per departure, so such a
878 + // booking reserved no seats at all.
879 + //
880 + // Added as an extra field rather than by changing the row shape, so every
881 + // existing consumer of this endpoint is unaffected.
882 + $timesByDate = [];
883 + try {
884 + $resolver = new \Yatra\Services\AvailabilityResolutionService();
885 + foreach ($resolver->getAllAvailabilityDates($tripId, $fromDate, $toDate) as $slot) {
886 + $slotDate = (string) ($slot->departure_date ?? $slot->date ?? '');
887 + $slotTime = trim((string) ($slot->departure_time ?? ''));
888 + if ($slotDate === '' || $slotTime === '') {
889 + continue;
890 + }
891 + $timesByDate[$slotDate][$slotTime] = true;
892 + }
893 + } catch (\Throwable $e) {
894 + $timesByDate = [];
895 + }
896 +
897 + foreach ($dates as $key => $row) {
898 + $rowDate = is_array($row) ? (string) ($row['date'] ?? '') : (string) ($row->date ?? '');
899 + $times = isset($timesByDate[$rowDate]) ? array_keys($timesByDate[$rowDate]) : [];
900 + sort($times);
901 +
902 + if (is_array($row)) {
903 + $dates[$key]['departure_times'] = $times;
904 + } elseif (is_object($row)) {
905 + $row->departure_times = $times;
906 + }
907 + }
908 +
722 909 return new WP_REST_Response([
723 910 'success' => true,
724 911 'data' => $dates,
725 912 ]);