service = new AvailabilityService(new AvailabilityRepository()); } /** * Register routes */ public function register_routes(): void { $namespace = 'yatra/v1'; $base = 'availability'; // Collection routes — view cap for reads, edit cap for writes. register_rest_route($namespace, '/' . $base, [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_items'], 'permission_callback' => [$this, 'check_view_permission'], 'args' => [ 'trip_id' => [ 'required' => true, 'type' => 'integer', 'validate_callback' => function ($param) { return is_numeric($param) && $param > 0; }, ], 'status' => [ 'type' => 'string', 'default' => 'all', ], 'month' => [ 'type' => 'string', 'default' => 'all', ], 'search' => [ 'type' => 'string', 'default' => '', ], 'page' => [ 'type' => 'integer', 'default' => 1, 'minimum' => 1, ], 'per_page' => [ 'type' => 'integer', 'default' => 50, 'minimum' => 1, 'maximum' => 100, ], ], ], [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'create_item'], 'permission_callback' => [$this, 'check_permission'], ], ]); // Single item routes — view cap for read, edit cap for the // mutations. Delete uses edit as well — there's no separate // "delete availability date" cap in the registry because // removing a date is functionally part of trip availability // editing, not a destructive operation in its own right. register_rest_route($namespace, '/' . $base . '/(?P[\d]+)', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_item'], 'permission_callback' => [$this, 'check_view_permission'], ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'update_item'], 'permission_callback' => [$this, 'check_permission'], ], [ 'methods' => \WP_REST_Server::DELETABLE, 'callback' => [$this, 'delete_item'], 'permission_callback' => [$this, 'check_permission'], ], ]); register_rest_route($namespace, '/' . $base . '/(?P[\d]+)/duplicate', [ [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'duplicate_item'], 'permission_callback' => [$this, 'check_permission'], ], ]); } /** * Get all availability dates for a trip */ public function get_items(WP_REST_Request $request) { try { $tripId = (int) $request->get_param('trip_id'); if ($tripId <= 0) { return new WP_Error( 'invalid_trip_id', 'Valid trip_id is required', ['status' => 400] ); } $filters = [ 'status' => $request->get_param('status') ?? 'all', 'month' => $request->get_param('month') ?? 'all', 'search' => $request->get_param('search') ?? '', 'page' => (int) ($request->get_param('page') ?? 1), 'per_page' => (int) ($request->get_param('per_page') ?? 50), ]; $items = $this->service->getByTripId($tripId, $filters); $total = $this->service->countByTripId($tripId, $filters); global $wpdb; $bookingsTable = BookingsTable::getTableName(); $availabilityIdByDate = []; $dateCounts = []; foreach ($items as $item) { $date = (string) ($item->departure_date ?? ''); if ($date === '') { continue; } $dateCounts[$date] = ($dateCounts[$date] ?? 0) + 1; $availabilityIdByDate[$date] = (int) ($item->id ?? 0); } foreach ($dateCounts as $date => $count) { if ($count !== 1) { unset($availabilityIdByDate[$date]); } } // Use AvailabilityService to update booking availability IDs if (!empty($availabilityIdByDate)) { $this->service->updateBookingAvailabilityIds((int) $tripId, $availabilityIdByDate); } // Aggregate bookings count per availability date for this trip // Use AvailabilityService to get booking counts $countsByAvailabilityId = []; $bookingCounts = $this->service->getBookingCountsByAvailabilityIds(array_column($items, 'id')); foreach ($bookingCounts as $row) { $aid = (int) ($row->availability_id ?? 0); if ($aid > 0) { $countsByAvailabilityId[$aid] = (int) ($row->booked_count ?? 0); } } $data = array_map(function ($item) use ($request, $countsByAvailabilityId) { $prepared = $this->prepare_item_for_response($item, $request); $availabilityId = (int) ($prepared['id'] ?? 0); $bookedCount = 0; if ($availabilityId > 0 && isset($countsByAvailabilityId[$availabilityId])) { $bookedCount = (int) $countsByAvailabilityId[$availabilityId]; } $seatsTotal = (int) ($prepared['seats_total'] ?? 0); $seatsReserved = (int) ($prepared['seats_reserved'] ?? 0); $available = max(0, $seatsTotal - $bookedCount); $prepared['booked_seats'] = $bookedCount; $prepared['total_seats'] = $seatsTotal; $prepared['available_seats'] = $available; $prepared['seats_available'] = $available; // Preserve original database status - don't override calculated status // The status should reflect what's actually stored in the database $original_status = $prepared['status'] ?? 'available'; // Only update status if seats are actually sold out (0 available) if ($available === 0 && $original_status !== 'blocked' && $original_status !== 'closed' && $original_status !== 'cancelled') { $prepared['status'] = 'sold_out'; } // For all other cases, preserve the original database status // This allows 'available', 'limited', 'blocked', 'closed', 'cancelled' to show correctly return $prepared; }, $items); return new WP_REST_Response([ 'dates' => $data, 'total' => $total, 'page' => $filters['page'], 'per_page' => $filters['per_page'], ], 200); } catch (\Exception $e) { return new WP_Error( 'availability_fetch_error', $e->getMessage(), ['status' => 500] ); } } public function duplicate_item(WP_REST_Request $request) { try { $id = (int) $request->get_param('id'); $data = $request->get_json_params(); if (empty($data)) { $data = $request->get_body_params(); } $item = $this->service->duplicate($id, is_array($data) ? $data : []); return new WP_REST_Response($this->prepare_item_for_response($item, $request), 201); } catch (\InvalidArgumentException $e) { return new WP_Error( 'validation_error', $e->getMessage(), ['status' => 400] ); } catch (\Exception $e) { return new WP_Error( 'availability_duplicate_error', $e->getMessage(), ['status' => 500] ); } } /** * Get single availability date */ public function get_item(WP_REST_Request $request) { try { $id = (int) $request->get_param('id'); $item = $this->service->getById($id); if (!$item) { return new WP_Error( 'availability_not_found', 'Availability date not found', ['status' => 404] ); } $prepared = $this->prepare_item_for_response($item, $request); // Compute live booked seats for this availability_id if (!empty($prepared['id'])) { // Use AvailabilityService to get booked count $bookedCount = $this->service->getBookedCountByAvailabilityId((int) $prepared['id']); $seatsTotal = (int) ($prepared['seats_total'] ?? 0); $available = max(0, $seatsTotal - $bookedCount); $prepared['booked_seats'] = $bookedCount; $prepared['seats_available'] = $available; } return new WP_REST_Response($prepared, 200); } catch (\Exception $e) { return new WP_Error( 'availability_fetch_error', $e->getMessage(), ['status' => 500] ); } } /** * Create availability date */ public function create_item(WP_REST_Request $request) { try { $data = $request->get_json_params(); if (empty($data)) { $data = $request->get_body_params(); } $item = $this->service->create($data); // Trigger hook to sync departure capacity do_action('yatra_availability_updated', $item->id); return new WP_REST_Response($this->prepare_item_for_response($item, $request), 201); } catch (\InvalidArgumentException $e) { return new WP_Error( 'validation_error', $e->getMessage(), ['status' => 400] ); } catch (\Exception $e) { return new WP_Error( 'availability_create_error', $e->getMessage(), ['status' => 500] ); } } /** * Update availability date */ public function update_item(WP_REST_Request $request) { try { $id = (int) $request->get_param('id'); $data = $request->get_json_params(); if (empty($data)) { $data = $request->get_body_params(); } $item = $this->service->update($id, $data); // Trigger hook to sync departure capacity do_action('yatra_availability_updated', $id); return new WP_REST_Response($this->prepare_item_for_response($item, $request), 200); } catch (\InvalidArgumentException $e) { return new WP_Error( 'validation_error', $e->getMessage(), ['status' => 400] ); } catch (\Exception $e) { return new WP_Error( 'availability_update_error', $e->getMessage(), ['status' => 500] ); } } /** * Delete availability date */ public function delete_item(WP_REST_Request $request) { try { $id = (int) $request->get_param('id'); $this->service->delete($id); return new WP_REST_Response([ 'message' => 'Availability date deleted successfully', 'id' => $id, ], 200); } catch (\InvalidArgumentException $e) { return new WP_Error( 'validation_error', $e->getMessage(), ['status' => 400] ); } catch (\Exception $e) { return new WP_Error( 'availability_delete_error', $e->getMessage(), ['status' => 500] ); } } /** * Prepare item for response */ protected function prepare_item_for_response($item, WP_REST_Request $request): array { $data = (array) $item; // Format prices as strings for frontend if (isset($data['original_price'])) { $data['original_price'] = $data['original_price'] !== null ? number_format((float) $data['original_price'], 2, '.', '') : null; } if (isset($data['discounted_price'])) { $data['discounted_price'] = $data['discounted_price'] !== null ? number_format((float) $data['discounted_price'], 2, '.', '') : null; } if (isset($data['discount_percentage'])) { $data['discount_percentage'] = $data['discount_percentage'] !== null ? number_format((float) $data['discount_percentage'], 2, '.', '') : null; } // Ensure pricing_type has a default value if (!isset($data['pricing_type']) || empty($data['pricing_type'])) { $data['pricing_type'] = 'regular'; } // Decode price_types JSON string from DB and ensure it's an array if (isset($data['price_types']) && is_string($data['price_types'])) { $decoded = json_decode($data['price_types'], true); $data['price_types'] = is_array($decoded) ? $decoded : []; } elseif (!isset($data['price_types']) || !is_array($data['price_types'])) { $data['price_types'] = []; } // Ensure status matches frontend expectations if ($data['status'] === 'blocked' || !empty($data['is_blocked'])) { $data['status'] = 'blocked'; $data['is_blocked'] = true; } return $data; } /** * Check permission */ /** * Write permission — trip-edits cap. Adding, updating, deleting, * and duplicating availability dates all mutate trip data, so the * registered `yatra_edit_trips` cap is the right gate. WP admins * pass via the Team module's admin-fallback filter. */ public function check_permission(?WP_REST_Request $request = null): bool { return current_user_can('yatra_edit_trips'); } /** * Read permission — view-trips cap. Listing availability dates is * a read-only operation against trip data; Sales Agent / Front * Desk / Guide / Accountant / Auditor roles all hold this. */ public function check_view_permission(?WP_REST_Request $request = null): bool { return current_user_can('yatra_view_trips'); } }