departureService = new DepartureService($departureRepo); $this->ruleService = new RecurringRuleService($ruleRepo, $departureRepo); } /** * Register routes */ public function register_routes(): void { $namespace = 'yatra/v1'; // All departures endpoint (without trip ID) — view cap. register_rest_route($namespace, '/departures', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_all_departures'], 'permission_callback' => [$this, 'check_view_permission'], ], ]); $base = 'trips/(?P[\d]+)/departures'; // Departures list + create — view cap for read, manage cap // for create (a departure is a scheduled trip instance, not // trip content edit). register_rest_route($namespace, '/' . $base, [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_departures'], 'permission_callback' => [$this, 'check_view_permission'], ], [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'create_departure'], 'permission_callback' => [$this, 'check_manage_permission'], ], ]); // Single departure — view / update / delete. Update is a // manage operation; DELETE is the cancellation cap because // dropping a departure typically means cancelling it. register_rest_route($namespace, '/' . $base . '/(?P[\d]+)', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_departure'], 'permission_callback' => [$this, 'check_view_permission'], ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'update_departure'], 'permission_callback' => [$this, 'check_manage_permission'], ], [ 'methods' => \WP_REST_Server::DELETABLE, 'callback' => [$this, 'delete_departure'], 'permission_callback' => [$this, 'check_cancel_permission'], ], ]); // Past departures endpoint — view cap. register_rest_route($namespace, '/' . $base . '/past', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_past_departures'], 'permission_callback' => [$this, 'check_view_permission'], ], ]); // Available dates endpoint (for frontend booking widget). register_rest_route($namespace, '/trips/(?P[\d]+)/available-dates', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_available_dates'], 'permission_callback' => '__return_true', // Public endpoint ], ]); // Recurring rules — these are availability templates on the // TRIP, not on individual departures. Gated on the trip-edit // cap (same as the other availability controllers). $rulesBase = 'trips/(?P[\d]+)/recurring-rules'; register_rest_route($namespace, '/' . $rulesBase, [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_recurring_rules'], 'permission_callback' => [$this, 'check_view_permission'], ], [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'create_recurring_rule'], 'permission_callback' => [$this, 'check_trip_edit_permission'], ], ]); register_rest_route($namespace, '/' . $rulesBase . '/(?P[\d]+)', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_recurring_rule'], 'permission_callback' => [$this, 'check_view_permission'], ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'update_recurring_rule'], 'permission_callback' => [$this, 'check_trip_edit_permission'], ], [ 'methods' => \WP_REST_Server::DELETABLE, 'callback' => [$this, 'delete_recurring_rule'], 'permission_callback' => [$this, 'check_trip_edit_permission'], ], ]); // Preview recurring-rule dates — view cap. register_rest_route($namespace, '/' . $rulesBase . '/(?P[\d]+)/preview', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'preview_recurring_rule'], 'permission_callback' => [$this, 'check_view_permission'], ], ]); } /** * Granular cap checks for every Departure endpoint. The previous * implementation gated everything on `manage_options` which * locked Sales Agent / Front Desk / Guide / Accountant / Auditor * out of the departures REST surface despite the role bundles * granting them view / manage / cancel caps. WP admins pass via * the Team module's admin-fallback filter. */ public function check_view_permission(?WP_REST_Request $request = null): bool { return current_user_can('yatra_view_departures'); } public function check_manage_permission(?WP_REST_Request $request = null): bool { // Held by Owner / Manager / Guide. Used for create + update. return current_user_can('yatra_manage_departures'); } public function check_cancel_permission(?WP_REST_Request $request = null): bool { // Held by Owner / Manager only by default. Cancelling a // departure is a customer-affecting action (refunds, emails) // so it gets the stricter cap than ordinary management. return current_user_can('yatra_cancel_departures'); } public function check_trip_edit_permission(?WP_REST_Request $request = null): bool { // Recurring rules belong to the parent trip, not to any one // departure. Their lifecycle matches the trip-edit cap. return current_user_can('yatra_edit_trips'); } /** * @deprecated Kept for any external code referencing the old * method. Routes to view — safer default than the old * `manage_options` shorthand. Admin users still pass via the * admin-fallback layer. */ public function check_permission(?WP_REST_Request $request = null): bool { return $this->check_view_permission($request); } // ========================================================================= // DEPARTURES ENDPOINTS // ========================================================================= /** * Sanitised pagination for the departure list endpoints. * * Returns [page, per_page]. per_page is 0 when the caller did not ask for * pagination, so the list keeps returning every matching row for * consumers that never sent it (the previous behaviour); page is always * >= 1. Only when per_page > 0 is a LIMIT / OFFSET window applied. * * @return array{0: int, 1: int} */ private function paginationParams(WP_REST_Request $request): array { $perPage = max(0, (int) $request->get_param('per_page')); $page = max(1, (int) $request->get_param('page')); return [$page, $perPage]; } /** * GET /trips/{trip_id}/departures */ public function get_departures(WP_REST_Request $request): WP_REST_Response { $tripId = (int) $request->get_param('trip_id'); $status = $request->get_param('status'); $availability = $request->get_param('availability'); $search = $request->get_param('search'); $source = $request->get_param('source'); $dateFrom = $request->get_param('date_from'); $dateTo = $request->get_param('date_to'); $includePast = $request->get_param('include_past') !== 'false'; $filters = []; if ($status) $filters['status'] = $status; // Capacity is filtered independently of status (see DepartureRepository::applyAvailabilityClause). if ($availability && in_array($availability, ['available', 'partial', 'full'], true)) $filters['availability'] = $availability; // Free-text search on date / notes (see DepartureRepository::applySearchClause). if (is_string($search) && trim($search) !== '') $filters['search'] = trim($search); if ($source) $filters['source'] = $source; if ($dateFrom && trim($dateFrom) !== '') $filters['date_from'] = $dateFrom; if ($dateTo && trim($dateTo) !== '') $filters['date_to'] = $dateTo; $filters['include_past'] = $includePast; try { // Server-side pagination, only when the caller asks for it. [$page, $perPage] = $this->paginationParams($request); if ($perPage > 0) { $filters['per_page'] = $perPage; $filters['page'] = $page; } // True total for the SAME filters, independent of the page window — // count($departures) was the size of the returned page, not the total. $total = $this->departureService->countByTripId($tripId, $filters); $departures = $this->departureService->getByTripId($tripId, $filters); // Get trip information $tripRepository = new \Yatra\Repositories\TripRepository(); $trip = $tripRepository->find($tripId); // Get booking departure repository for booking links $bookingDepartureRepo = new \Yatra\Repositories\BookingDepartureRepository(); $travellerRepo = new \Yatra\Repositories\TravellerRepository(); $bookingRepo = new \Yatra\Repositories\BookingRepository(); // Get capacity service to sync capacity from availability $capacityService = new \Yatra\Services\CapacityService(); $departureRepo = new \Yatra\Repositories\DepartureRepository(); return new WP_REST_Response([ 'success' => true, 'data' => array_map(function ($d) use ($trip, $bookingDepartureRepo, $travellerRepo, $bookingRepo, $capacityService, $departureRepo) { // Sync capacity from availability before returning $date = $d->start_date ?: $d->date; $correctCapacity = $capacityService->getCapacityForDate($d->trip_id, $date, $d->time ?? null); if ($correctCapacity > 0 && $d->max_capacity !== $correctCapacity) { $departureRepo->update($d->id, ['max_capacity' => $correctCapacity]); $d->max_capacity = $correctCapacity; } // Promote a departure that has taken place to 'past' so a // completed departure is never shown with (or hidden behind) a // stale 'upcoming'/'full' status — the daily cron may not have // run. Cancelled/trashed departures are left as-is. This keeps // the status badge and the tab counts date-accurate. if (!in_array($d->status, ['cancelled', 'trash', 'past'], true)) { $checkDate = (!empty($d->end_date) && $d->end_date !== '0000-00-00') ? $d->end_date : ((!empty($d->start_date) && $d->start_date !== '0000-00-00') ? $d->start_date : $d->date); if (!empty($checkDate) && $checkDate < date('Y-m-d')) { $departureRepo->update($d->id, ['status' => 'past']); $d->status = 'past'; } } $departureArray = $d->toArray(); // Add trip information if ($trip) { $departureArray['trip'] = [ 'id' => (int) $trip->id, 'title' => $trip->title ?? '', 'slug' => $trip->slug ?? '', ]; } // Add booking links and get travelers $bookingIds = $bookingDepartureRepo->getBookingIdsForDeparture($d->id); $departureArray['booking_ids'] = $bookingIds; $departureArray['bookings_count'] = count($bookingIds); // Recalculate revenue for departures with bookings if (!empty($bookingIds)) { try { // Call the service method to recalculate revenue $totalRevenue = 0.00; foreach ($bookingIds as $bookingId) { $booking = $bookingRepo->find($bookingId); if ($booking && !empty($booking->total_amount)) { $totalRevenue += (float) $booking->total_amount; } } // Set revenue in the array directly $departureArray['total_revenue'] = $totalRevenue; } catch (\Exception $e) { // If any error occurs, leave the original value } } // Get all travelers for this departure $allTravelers = []; foreach ($bookingIds as $bookingId) { $travelers = $travellerRepo->getByBookingId($bookingId); $booking = $bookingRepo->find($bookingId); foreach ($travelers as $traveler) { $fields = $traveler['fields'] ?? []; $firstName = $fields['first_name'] ?? $traveler['first_name'] ?? ($booking->contact_first_name ?? ''); $lastName = $fields['last_name'] ?? $traveler['last_name'] ?? ($booking->contact_last_name ?? ''); $email = $fields['email'] ?? $fields['contact_email'] ?? $fields['primary_email'] ?? ($booking->contact_email ?? ''); $phone = $fields['phone'] ?? $fields['contact_phone'] ?? $fields['mobile_phone'] ?? $fields['whatsapp'] ?? ($booking->contact_phone ?? ''); $allTravelers[] = [ 'id' => (int) $traveler['id'], 'booking_id' => $bookingId, 'booking_reference' => $booking ? ($booking->reference ?? '') : '', 'is_lead' => (bool) ($traveler['is_lead'] ?? false), 'first_name' => $firstName, 'last_name' => $lastName, 'email' => $email, 'phone' => $phone, ]; } } $departureArray['travelers'] = $allTravelers; $departureArray['travelers_count'] = count($allTravelers); // Format time for display (remove seconds if present) if (!empty($departureArray['time'])) { $time = $departureArray['time']; // Convert HH:MM:SS to HH:MM if needed if (strlen($time) > 5 && substr_count($time, ':') === 2) { $departureArray['time'] = substr($time, 0, 5); } } // Debug: Log time and revenue values return $departureArray; }, $departures), 'meta' => [ 'total' => $total, 'page' => $page, 'per_page' => $perPage > 0 ? $perPage : $total, 'total_pages' => $perPage > 0 ? max(1, (int) ceil($total / $perPage)) : 1, ], ]); } catch (\Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } /** * GET /trips/{trip_id}/departures/{id} */ public function get_departure(WP_REST_Request $request): WP_REST_Response { $id = (int) $request->get_param('id'); $tripId = (int) $request->get_param('trip_id'); $repo = new DepartureRepository(); $departure = $repo->findModel($id); if (!$departure) { return new WP_REST_Response([ 'success' => false, 'message' => 'Departure not found', ], 404); } // Sync capacity from availability before returning $capacityService = new \Yatra\Services\CapacityService(); $date = $departure->start_date ?: $departure->date; $correctCapacity = $capacityService->getCapacityForDate($departure->trip_id, $date, $departure->time ?? null); if ($correctCapacity > 0 && $departure->max_capacity !== $correctCapacity) { $repo->update($departure->id, ['max_capacity' => $correctCapacity]); $departure->max_capacity = $correctCapacity; } // Base departure array $departureArray = $departure->toArray(); // Trip information $tripRepository = new \Yatra\Repositories\TripRepository(); $trip = $tripRepository->find($tripId ?: $departure->trip_id); if ($trip) { // Log trip data for debugging \Yatra\Utils\Logger::info("Trip data for departure {$departure->id}: " . json_encode([ 'duration' => $trip->duration ?? 'NULL', 'group_type' => $trip->group_type ?? 'NULL', 'difficulty_level' => $trip->difficulty_level ?? 'NULL', 'min_travelers' => $trip->min_travelers ?? 'NULL', 'max_travelers' => $trip->max_travelers ?? 'NULL', ])); // Fetch difficulty level name from difficulty_levels table $difficultyLevelName = ''; if (!empty($trip->difficulty_level) && is_numeric($trip->difficulty_level)) { $difficultyRepo = new \Yatra\Repositories\DifficultyLevelRepository(); $difficultyLevel = $difficultyRepo->find((int) $trip->difficulty_level); if ($difficultyLevel) { $difficultyLevelName = $difficultyLevel->name ?? ''; } } // Fetch group type name from traveler_categories table $groupTypeName = ''; if (!empty($trip->group_type) && is_numeric($trip->group_type)) { $travelerCategoryRepo = new \Yatra\Repositories\TravelerCategoryRepository(); $travelerCategory = $travelerCategoryRepo->find((int) $trip->group_type); if ($travelerCategory) { $groupTypeName = $travelerCategory->name ?? ''; } } $departureArray['trip'] = [ 'id' => (int) $trip->id, 'title' => $trip->title ?? '', 'slug' => $trip->slug ?? '', 'summary' => $trip->short_description ?? $trip->excerpt ?? $trip->summary ?? '', 'starting_location' => $trip->starting_location ?? '', 'ending_location' => $trip->ending_location ?? '', 'difficulty_level' => $difficultyLevelName, 'group_type' => $groupTypeName, 'min_travelers' => $trip->min_travelers ?? null, 'max_travelers' => $trip->max_travelers ?? null, 'duration' => $trip->duration ?? null, 'price' => $trip->price ?? null, 'created_at' => $trip->created_at ?? '', ]; } // Related bookings and travelers (mirror get_departures logic) $bookingDepartureRepo = new \Yatra\Repositories\BookingDepartureRepository(); $travellerRepo = new \Yatra\Repositories\TravellerRepository(); $bookingRepo = new \Yatra\Repositories\BookingRepository(); $bookingIds = $bookingDepartureRepo->getBookingIdsForDeparture($departure->id); $departureArray['booking_ids'] = $bookingIds; $departureArray['bookings_count'] = count($bookingIds); // Calculate total revenue from bookings (simple sum of total_amount like list endpoint) if (!empty($bookingIds)) { try { $totalRevenue = 0.00; foreach ($bookingIds as $bookingId) { $booking = $bookingRepo->find($bookingId); if ($booking && !empty($booking->total_amount)) { $totalRevenue += (float) $booking->total_amount; } } $departureArray['total_revenue'] = $totalRevenue; } catch (\Exception $e) { // Leave original value on error if (defined('WP_DEBUG') && WP_DEBUG) { } } } // Travelers linked to this departure $allTravelers = []; foreach ($bookingIds as $bookingId) { $travelers = $travellerRepo->getByBookingId($bookingId); $booking = $bookingRepo->find($bookingId); foreach ($travelers as $traveler) { $fields = $traveler['fields'] ?? []; $firstName = $fields['first_name'] ?? $traveler['first_name'] ?? ($booking->contact_first_name ?? ''); $lastName = $fields['last_name'] ?? $traveler['last_name'] ?? ($booking->contact_last_name ?? ''); $email = $fields['email'] ?? $fields['contact_email'] ?? $fields['primary_email'] ?? ($booking->contact_email ?? ''); $phone = $fields['phone'] ?? $fields['contact_phone'] ?? $fields['mobile_phone'] ?? $fields['whatsapp'] ?? ($booking->contact_phone ?? ''); $allTravelers[] = [ 'id' => (int) $traveler['id'], 'booking_id' => $bookingId, 'booking_reference' => $booking ? ($booking->reference ?? '') : '', 'is_lead' => (bool) ($traveler['is_lead'] ?? false), 'first_name' => $firstName, 'last_name' => $lastName, 'email' => $email, 'phone' => $phone, ]; } } $departureArray['travelers'] = $allTravelers; $departureArray['travelers_count'] = count($allTravelers); // Format time (HH:MM) if (!empty($departureArray['time'])) { $time = $departureArray['time']; if (strlen($time) > 5 && substr_count($time, ':') === 2) { $departureArray['time'] = substr($time, 0, 5); } } return new WP_REST_Response([ 'success' => true, 'data' => $departureArray, ]); } /** * POST /trips/{trip_id}/departures */ public function create_departure(WP_REST_Request $request): WP_REST_Response { $tripId = (int) $request->get_param('trip_id'); $data = $request->get_json_params(); $data['trip_id'] = $tripId; try { $id = $this->departureService->create($data); $repo = new DepartureRepository(); $departure = $repo->findModel($id); return new WP_REST_Response([ 'success' => true, 'data' => $departure->toArray(), 'message' => 'Departure created successfully', ], 201); } catch (\Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } /** * PUT /trips/{trip_id}/departures/{id} */ public function update_departure(WP_REST_Request $request): WP_REST_Response { $id = (int) $request->get_param('id'); $data = $request->get_json_params(); try { $this->departureService->update($id, $data); $repo = new DepartureRepository(); $departure = $repo->findModel($id); return new WP_REST_Response([ 'success' => true, 'data' => $departure->toArray(), 'message' => 'Departure updated successfully', ]); } catch (\Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } /** * DELETE /trips/{trip_id}/departures/{id} */ public function delete_departure(WP_REST_Request $request): WP_REST_Response { $id = (int) $request->get_param('id'); try { $this->departureService->delete($id); return new WP_REST_Response([ 'success' => true, 'message' => 'Departure deleted successfully', ]); } catch (\Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } /** * GET /departures * Get departures from all trips */ public function get_all_departures(WP_REST_Request $request): WP_REST_Response { $status = $request->get_param('status'); $availability = $request->get_param('availability'); $search = $request->get_param('search'); $source = $request->get_param('source'); $dateFrom = $request->get_param('date_from'); $dateTo = $request->get_param('date_to'); $includePast = $request->get_param('include_past') !== 'false'; $filters = []; if ($status) $filters['status'] = $status; // Capacity is filtered independently of status (see DepartureRepository::applyAvailabilityClause). if ($availability && in_array($availability, ['available', 'partial', 'full'], true)) $filters['availability'] = $availability; // Free-text search on date / notes (see DepartureRepository::applySearchClause). if (is_string($search) && trim($search) !== '') $filters['search'] = trim($search); if ($source) $filters['source'] = $source; if ($dateFrom && trim($dateFrom) !== '') $filters['date_from'] = $dateFrom; if ($dateTo && trim($dateTo) !== '') $filters['date_to'] = $dateTo; $filters['include_past'] = $includePast; try { // Server-side pagination, only when the caller asks for it. [$page, $perPage] = $this->paginationParams($request); if ($perPage > 0) { $filters['per_page'] = $perPage; $filters['page'] = $page; } // True total for the SAME filters, independent of the page window — // count($processed) was the size of the returned page, not the total. $total = $this->departureService->countAllDepartures($filters); // Get all departures (no trip filter) $departures = $this->departureService->getAllDepartures($filters); // Get repository for additional data $bookingDepartureRepo = new \Yatra\Repositories\BookingDepartureRepository(); $travellerRepo = new \Yatra\Repositories\TravellerRepository(); $bookingRepo = new \Yatra\Repositories\BookingRepository(); $tripRepository = new \Yatra\Repositories\TripRepository(); // Get capacity service to sync capacity from availability $capacityService = new \Yatra\Services\CapacityService(); $departureRepo = new \Yatra\Repositories\DepartureRepository(); // Process each departure to add related data $processed = array_map(function ($d) use ($tripRepository, $bookingDepartureRepo, $travellerRepo, $bookingRepo, $capacityService, $departureRepo) { // Sync capacity from availability before returning $date = $d->start_date ?: $d->date; $correctCapacity = $capacityService->getCapacityForDate($d->trip_id, $date, $d->time ?? null); if ($correctCapacity > 0) { if ((int) $d->max_capacity !== $correctCapacity) { $departureRepo->update($d->id, ['max_capacity' => $correctCapacity]); $d->max_capacity = $correctCapacity; } } elseif ((int) $d->max_capacity >= 9999) { // Normalise a legacy "unlimited"/junk capacity sentinel (e.g. // 9999/11111) to the canonical unlimited value 0, so it isn't // shown as a huge literal number here while the dashboard renders // >= 9999 as 0 — the two disagreeing on capacity and occupancy. // // Deliberately heal to 0 (NOT the trip's max_travelers): 0 means // "unlimited" to both the capacity guard (incrementBookedCount) // and Departure::calculateStatus(), so healing can never flip an // already (over-)booked departure to 'full' — which capping to a // smaller number would, and 'full' departures drop out of the // dashboard's upcoming view. This keeps the sentinel's original // "unlimited" meaning while making both surfaces agree. if ((int) $d->max_capacity !== 0) { $departureRepo->update($d->id, ['max_capacity' => 0]); $d->max_capacity = 0; } } // Promote a departure that has taken place to 'past' so a completed // departure is never shown with (or hidden behind) a stale // 'upcoming'/'full' status — the daily cron may not have run. // Cancelled/trashed departures are left as-is. Keeps the status // badge and the dashboard/tab counts date-accurate. if (!in_array($d->status, ['cancelled', 'trash', 'past'], true)) { $checkDate = (!empty($d->end_date) && $d->end_date !== '0000-00-00') ? $d->end_date : ((!empty($d->start_date) && $d->start_date !== '0000-00-00') ? $d->start_date : $d->date); if (!empty($checkDate) && $checkDate < date('Y-m-d')) { $departureRepo->update($d->id, ['status' => 'past']); $d->status = 'past'; } } $departureArray = $d->toArray(); // Add trip information $trip = $tripRepository->find($d->trip_id); if ($trip) { $departureArray['trip'] = [ 'id' => (int) $trip->id, 'title' => $trip->title ?? '', 'slug' => $trip->slug ?? '', ]; } // Add booking links and get travelers $bookingIds = $bookingDepartureRepo->getBookingIdsForDeparture($d->id); $departureArray['booking_ids'] = $bookingIds; $departureArray['bookings_count'] = count($bookingIds); // Recalculate revenue for departures with bookings if (!empty($bookingIds)) { try { $totalRevenue = 0.00; foreach ($bookingIds as $bookingId) { $booking = $bookingRepo->find($bookingId); if ($booking && !empty($booking->total_amount)) { $totalRevenue += (float) $booking->total_amount; } } $departureArray['total_revenue'] = $totalRevenue; } catch (\Exception $e) { } } // Get all travelers for this departure $allTravelers = []; foreach ($bookingIds as $bookingId) { $travelers = $travellerRepo->getByBookingId($bookingId); $booking = $bookingRepo->find($bookingId); foreach ($travelers as $traveler) { $fields = $traveler['fields'] ?? []; $firstName = $fields['first_name'] ?? $traveler['first_name'] ?? ($booking->contact_first_name ?? ''); $lastName = $fields['last_name'] ?? $traveler['last_name'] ?? ($booking->contact_last_name ?? ''); $email = $fields['email'] ?? $fields['contact_email'] ?? $fields['primary_email'] ?? ($booking->contact_email ?? ''); $phone = $fields['phone'] ?? $fields['contact_phone'] ?? $fields['mobile_phone'] ?? $fields['whatsapp'] ?? ($booking->contact_phone ?? ''); $allTravelers[] = [ 'id' => (int) $traveler['id'], 'booking_id' => $bookingId, 'booking_reference' => $booking ? ($booking->reference ?? '') : '', 'is_lead' => (bool) ($traveler['is_lead'] ?? false), 'first_name' => $firstName, 'last_name' => $lastName, 'email' => $email, 'phone' => $phone, ]; } } $departureArray['travelers'] = $allTravelers; $departureArray['travelers_count'] = count($allTravelers); // Format time for display (remove seconds if present) if (!empty($departureArray['time'])) { $time = $departureArray['time']; if (strlen($time) > 5 && substr_count($time, ':') === 2) { $departureArray['time'] = substr($time, 0, 5); } } return $departureArray; }, $departures); return new WP_REST_Response([ 'success' => true, 'data' => $processed, 'meta' => [ 'total' => $total, 'page' => $page, 'per_page' => $perPage > 0 ? $perPage : $total, 'total_pages' => $perPage > 0 ? max(1, (int) ceil($total / $perPage)) : 1, ], ]); } catch (\Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } /** * GET /trips/{trip_id}/departures/past */ public function get_past_departures(WP_REST_Request $request): WP_REST_Response { $tripId = (int) $request->get_param('trip_id'); try { $departures = $this->departureService->getPastByTripId($tripId); return new WP_REST_Response([ 'success' => true, 'data' => array_map(function ($d) { return $d->toArray(); }, $departures), ]); } catch (\Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } /** * GET /trips/{trip_id}/available-dates * Public endpoint for frontend to get available dates */ public function get_available_dates(WP_REST_Request $request): WP_REST_Response { $tripId = (int) $request->get_param('trip_id'); $fromDate = $request->get_param('from_date') ?: date('Y-m-d'); // An explicit to_date always wins; only the default follows the // configurable booking horizon (12 months unless changed). The default // is counted from TODAY — not from from_date — exactly as before, so a // client that sends only from_date gets the same window it always did. $toDate = $request->get_param('to_date') ?: yatra_get_availability_horizon_date(); try { $dates = $this->departureService->getAvailableDates($tripId, $fromDate, $toDate); // Attach the departure times each date actually runs. The list above is // keyed by date and reports `time => null` for rule-generated dates, so a // trip running several departures a day looked like a single slot — and // an operator booking it from the admin had no way to say which departure // the booking was for. Capacity is tracked per departure, so such a // booking reserved no seats at all. // // Added as an extra field rather than by changing the row shape, so every // existing consumer of this endpoint is unaffected. $timesByDate = []; try { $resolver = new \Yatra\Services\AvailabilityResolutionService(); foreach ($resolver->getAllAvailabilityDates($tripId, $fromDate, $toDate) as $slot) { $slotDate = (string) ($slot->departure_date ?? $slot->date ?? ''); $slotTime = trim((string) ($slot->departure_time ?? '')); if ($slotDate === '' || $slotTime === '') { continue; } $timesByDate[$slotDate][$slotTime] = true; } } catch (\Throwable $e) { $timesByDate = []; } foreach ($dates as $key => $row) { $rowDate = is_array($row) ? (string) ($row['date'] ?? '') : (string) ($row->date ?? ''); $times = isset($timesByDate[$rowDate]) ? array_keys($timesByDate[$rowDate]) : []; sort($times); if (is_array($row)) { $dates[$key]['departure_times'] = $times; } elseif (is_object($row)) { $row->departure_times = $times; } } return new WP_REST_Response([ 'success' => true, 'data' => $dates, ]); } catch (\Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } // ========================================================================= // RECURRING RULES ENDPOINTS // ========================================================================= /** * GET /trips/{trip_id}/recurring-rules */ public function get_recurring_rules(WP_REST_Request $request): WP_REST_Response { $tripId = (int) $request->get_param('trip_id'); $activeOnly = $request->get_param('active_only') === 'true'; try { $rules = $this->ruleService->getByTripId($tripId, $activeOnly); return new WP_REST_Response([ 'success' => true, 'data' => array_map(function ($r) { return $r->toArray(); }, $rules), ]); } catch (\Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } /** * GET /trips/{trip_id}/recurring-rules/{id} */ public function get_recurring_rule(WP_REST_Request $request): WP_REST_Response { $id = (int) $request->get_param('id'); $repo = new RecurringRuleRepository(); $rule = $repo->findModel($id); if (!$rule) { return new WP_REST_Response([ 'success' => false, 'message' => 'Recurring rule not found', ], 404); } return new WP_REST_Response([ 'success' => true, 'data' => $rule->toArray(), ]); } /** * POST /trips/{trip_id}/recurring-rules */ public function create_recurring_rule(WP_REST_Request $request): WP_REST_Response { $tripId = (int) $request->get_param('trip_id'); $data = $request->get_json_params(); $data['trip_id'] = $tripId; try { $id = $this->ruleService->create($data); $repo = new RecurringRuleRepository(); $rule = $repo->findModel($id); return new WP_REST_Response([ 'success' => true, 'data' => $rule->toArray(), 'message' => 'Recurring rule created successfully', ], 201); } catch (\Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } /** * PUT /trips/{trip_id}/recurring-rules/{id} */ public function update_recurring_rule(WP_REST_Request $request): WP_REST_Response { $id = (int) $request->get_param('id'); $data = $request->get_json_params(); try { $this->ruleService->update($id, $data); $repo = new RecurringRuleRepository(); $rule = $repo->findModel($id); return new WP_REST_Response([ 'success' => true, 'data' => $rule->toArray(), 'message' => 'Recurring rule updated successfully', ]); } catch (\Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } /** * DELETE /trips/{trip_id}/recurring-rules/{id} */ public function delete_recurring_rule(WP_REST_Request $request): WP_REST_Response { $id = (int) $request->get_param('id'); try { $this->ruleService->delete($id); return new WP_REST_Response([ 'success' => true, 'message' => 'Recurring rule deleted successfully', ]); } catch (\Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } /** * GET /trips/{trip_id}/recurring-rules/{id}/preview */ public function preview_recurring_rule(WP_REST_Request $request): WP_REST_Response { $id = (int) $request->get_param('id'); $count = (int) ($request->get_param('count') ?: 10); try { $dates = $this->ruleService->getPreviewDates($id, $count); return new WP_REST_Response([ 'success' => true, 'data' => $dates, ]); } catch (\Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage(), ], 400); } } }