| @@ -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 | |