From 81c97c31d720304fcaca9f3433865b25bf0d1a36 Mon Sep 17 00:00:00 2001 From: magdev Date: Tue, 3 Feb 2026 21:24:40 +0100 Subject: [PATCH] Implement Phase 10: REST API Endpoints (v0.10.0) - Add complete REST API infrastructure under src/Api/ - ResponseFormatter for standardized responses - RateLimiter with tiered limits (public 60/min, availability 30/min, booking 10/min, admin 120/min) - AbstractController base class with common functionality - BuildingsController: list, get, rooms endpoints - RoomsController: list, get, availability, calendar, search endpoints - BookingsController: CRUD + confirm/check-in/check-out status transitions - GuestsController: list, get, search, booking history (admin only) - ServicesController: list, get, calculate endpoints - PricingController: calculate, seasons endpoints - API settings tab with enable/disable toggles - Comprehensive API documentation in README Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 61 ++ PLAN.md | 14 +- README.md | 198 +++++ src/Api/Controllers/AbstractController.php | 382 ++++++++ src/Api/Controllers/BookingsController.php | 930 ++++++++++++++++++++ src/Api/Controllers/BuildingsController.php | 323 +++++++ src/Api/Controllers/GuestsController.php | 452 ++++++++++ src/Api/Controllers/PricingController.php | 278 ++++++ src/Api/Controllers/RoomsController.php | 768 ++++++++++++++++ src/Api/Controllers/ServicesController.php | 375 ++++++++ src/Api/RateLimiter.php | 194 ++++ src/Api/ResponseFormatter.php | 171 ++++ src/Api/RestApi.php | 113 +++ src/Plugin.php | 191 ++++ wp-bnb.php | 4 +- 15 files changed, 4447 insertions(+), 7 deletions(-) create mode 100644 src/Api/Controllers/AbstractController.php create mode 100644 src/Api/Controllers/BookingsController.php create mode 100644 src/Api/Controllers/BuildingsController.php create mode 100644 src/Api/Controllers/GuestsController.php create mode 100644 src/Api/Controllers/PricingController.php create mode 100644 src/Api/Controllers/RoomsController.php create mode 100644 src/Api/Controllers/ServicesController.php create mode 100644 src/Api/RateLimiter.php create mode 100644 src/Api/ResponseFormatter.php create mode 100644 src/Api/RestApi.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b63cb7..bef2378 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,67 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.0] - 2026-02-03 + +### Added + +- REST API Infrastructure: + - New `src/Api/` directory with complete REST API implementation + - `ResponseFormatter.php` - Standardized response formatting (success, collection, error responses) + - `RateLimiter.php` - Transient-based rate limiting with tiered limits + - `Controllers/AbstractController.php` - Base controller with common functionality + - `RestApi.php` - Main registration class with namespace `wp-bnb/v1` +- Buildings API: + - `GET /wp-bnb/v1/buildings` - List buildings with pagination and search + - `GET /wp-bnb/v1/buildings/{id}` - Get single building with address, contact, details + - `GET /wp-bnb/v1/buildings/{id}/rooms` - Get rooms in a building with status filter +- Rooms API: + - `GET /wp-bnb/v1/rooms` - List rooms with filters (building, room_type, amenities, capacity, status) + - `GET /wp-bnb/v1/rooms/{id}` - Get room details with gallery, pricing, amenities + - `GET /wp-bnb/v1/rooms/{id}/availability` - Check availability with price calculation + - `GET /wp-bnb/v1/rooms/{id}/calendar` - Get monthly calendar data + - `POST /wp-bnb/v1/availability/search` - Search available rooms by date range and criteria +- Bookings API: + - `POST /wp-bnb/v1/bookings` - Create booking (public, creates pending status) + - `GET /wp-bnb/v1/bookings` - List bookings with filters (admin) + - `GET /wp-bnb/v1/bookings/{id}` - Get booking details (admin) + - `PATCH /wp-bnb/v1/bookings/{id}` - Update booking (admin) + - `DELETE /wp-bnb/v1/bookings/{id}` - Cancel booking (admin) + - `POST /wp-bnb/v1/bookings/{id}/confirm` - Confirm pending booking (admin) + - `POST /wp-bnb/v1/bookings/{id}/check-in` - Check in guest (admin) + - `POST /wp-bnb/v1/bookings/{id}/check-out` - Check out guest (admin) +- Guests API (admin only): + - `GET /wp-bnb/v1/guests` - List guests with pagination + - `GET /wp-bnb/v1/guests/{id}` - Get guest details (excludes encrypted ID numbers) + - `GET /wp-bnb/v1/guests/search` - Search guests by name/email + - `GET /wp-bnb/v1/guests/{id}/bookings` - Get guest's booking history +- Services API: + - `GET /wp-bnb/v1/services` - List active services with categories + - `GET /wp-bnb/v1/services/{id}` - Get service details with pricing info + - `POST /wp-bnb/v1/services/{id}/calculate` - Calculate service price for booking +- Pricing API: + - `POST /wp-bnb/v1/pricing/calculate` - Full price calculation with services + - `GET /wp-bnb/v1/pricing/seasons` - Get configured seasons and pricing modifiers +- API Settings tab in plugin settings: + - Enable/disable REST API toggle + - Enable/disable rate limiting toggle + - Endpoint documentation table + - Authentication instructions + +### Changed + +- Plugin.php updated to initialize REST API on `rest_api_init` hook +- Settings page now has seven tabs: General, Pricing, License, Updates, Metrics, API +- README.md updated with comprehensive REST API documentation + +### Security + +- Rate limiting: public (60/min), availability (30/min), booking (10/min), admin (120/min) +- Admin endpoints require `edit_posts` capability +- Supports WordPress Application Passwords for external API access +- Client identification by user ID (authenticated) or IP address (anonymous) +- Proxy/Cloudflare IP detection via X-Forwarded-For and CF-Connecting-IP headers + ## [0.9.0] - 2026-02-03 ### Added diff --git a/PLAN.md b/PLAN.md index a164528..b9995c6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -194,17 +194,21 @@ This document outlines the implementation plan for the WP BnB Management plugin. - 24 panels with gauges, pie charts, and stat displays - [x] Update settings page to enable/disable metrics -### Phase 10: API Endpoints (v0.10.0) +### Phase 10: API Endpoints (v0.10.0) - Complete -- [ ] REST API for rooms -- [ ] REST API for availability -- [ ] REST API for bookings -- [ ] Authentication and rate limiting +- [x] REST API for rooms (list, details, availability, calendar) +- [x] REST API for availability (search available rooms) +- [x] REST API for bookings (CRUD, status transitions) +- [x] REST API for buildings, guests, services, pricing +- [x] Authentication (Application Passwords, edit_posts capability) +- [x] Transient-based rate limiting with tiered limits +- [x] API settings tab with enable/disable toggles ## Phase 11: Security Audit (v0.11.0) - [ ] Check for Wordpress best-practices - [ ] Review the code for OWASP Top 10, including XSS, XSRF, SQLi and other critical threads +- [ ] Test the API-Endpoints against a local live system under for common vulnerabilities ## Future Considerations (v1.0.0+) diff --git a/README.md b/README.md index 37f0099..e7d774f 100644 --- a/README.md +++ b/README.md @@ -444,6 +444,204 @@ The dashboard includes: - Today's check-ins/check-outs - Trend indicators +## REST API + +The plugin provides a comprehensive REST API for integration with external applications, mobile apps, and third-party services. + +### Enabling the API + +1. Navigate to **WP BnB → Settings → API** +2. Enable "Enable REST API" +3. Optionally enable rate limiting for protection against abuse + +### Base URL + +All API endpoints are prefixed with: + +```txt +https://your-site.com/wp-json/wp-bnb/v1/ +``` + +### Authentication + +**Public endpoints** (room listings, availability checks) require no authentication. + +**Admin endpoints** (booking management, guest data) require authentication via: + +- **Cookie + Nonce**: For same-domain JavaScript requests +- **Application Passwords**: For external applications (WordPress 5.6+, recommended) + +To create an Application Password: + +1. Go to **Users → Profile** +2. Scroll to "Application Passwords" +3. Enter a name and click "Add New Application Password" +4. Use the generated password with HTTP Basic Auth + +```bash +curl -u "username:app-password" https://site.com/wp-json/wp-bnb/v1/bookings +``` + +### Public Endpoints + +| Method | Endpoint | Description | +| ------ | -------- | ----------- | +| GET | `/buildings` | List all buildings | +| GET | `/buildings/{id}` | Get building details | +| GET | `/buildings/{id}/rooms` | Get rooms in a building | +| GET | `/rooms` | List/search rooms | +| GET | `/rooms/{id}` | Get room details | +| GET | `/rooms/{id}/availability` | Check room availability | +| GET | `/rooms/{id}/calendar` | Get monthly calendar data | +| POST | `/availability/search` | Search available rooms | +| GET | `/services` | List all services | +| GET | `/services/{id}` | Get service details | +| POST | `/pricing/calculate` | Calculate booking price | +| POST | `/bookings` | Create a new booking (pending status) | + +### Admin Endpoints + +| Method | Endpoint | Description | +| ------ | -------- | ----------- | +| GET | `/bookings` | List all bookings | +| GET | `/bookings/{id}` | Get booking details | +| PATCH | `/bookings/{id}` | Update a booking | +| DELETE | `/bookings/{id}` | Cancel a booking | +| POST | `/bookings/{id}/confirm` | Confirm a pending booking | +| POST | `/bookings/{id}/check-in` | Check in a guest | +| POST | `/bookings/{id}/check-out` | Check out a guest | +| GET | `/guests` | List all guests | +| GET | `/guests/{id}` | Get guest details | +| GET | `/guests/search` | Search guests | +| GET | `/guests/{id}/bookings` | Get guest's booking history | + +### Rate Limiting + +When enabled, rate limits are applied per client (by user ID or IP address): + +| Type | Limit | Applies To | +| ---- | ----- | ---------- | +| Public | 60/min | Room/building listings | +| Availability | 30/min | Availability and calendar endpoints | +| Booking | 10/min | Booking creation | +| Admin | 120/min | All admin endpoints | + +Rate limit headers are included in responses: + +- `X-RateLimit-Limit`: Maximum requests allowed +- `X-RateLimit-Remaining`: Requests remaining in window +- `X-RateLimit-Reset`: Unix timestamp when limit resets + +### Example: Check Room Availability + +```bash +curl "https://site.com/wp-json/wp-bnb/v1/rooms/42/availability?check_in=2026-03-15&check_out=2026-03-20" +``` + +Response: + +```json +{ + "available": true, + "room_id": 42, + "check_in": "2026-03-15", + "check_out": "2026-03-20", + "nights": 5, + "pricing": { + "base_price": 500.00, + "seasonal_modifier": 1.0, + "weekend_surcharge": 40.00, + "total": 540.00, + "currency": "CHF" + } +} +``` + +### Example: Create a Booking + +```bash +curl -X POST https://site.com/wp-json/wp-bnb/v1/bookings \ + -H "Content-Type: application/json" \ + -d '{ + "room_id": 42, + "check_in": "2026-03-15", + "check_out": "2026-03-20", + "guests": 2, + "guest_info": { + "first_name": "John", + "last_name": "Doe", + "email": "john@example.com", + "phone": "+41 79 123 4567" + }, + "services": [ + {"service_id": 5, "quantity": 1} + ], + "notes": "Late arrival expected" + }' +``` + +Response: + +```json +{ + "id": 123, + "reference": "BNB-2026-00042", + "status": "pending", + "room": { + "id": 42, + "title": "Deluxe Suite" + }, + "check_in": "2026-03-15", + "check_out": "2026-03-20", + "nights": 5, + "guests": 2, + "pricing": { + "room_total": 540.00, + "services_total": 50.00, + "grand_total": 590.00, + "currency": "CHF" + }, + "_links": { + "self": [{"href": "https://site.com/wp-json/wp-bnb/v1/bookings/123"}] + } +} +``` + +### Example: Search Available Rooms + +```bash +curl -X POST https://site.com/wp-json/wp-bnb/v1/availability/search \ + -H "Content-Type: application/json" \ + -d '{ + "check_in": "2026-03-15", + "check_out": "2026-03-20", + "guests": 2, + "amenities": ["wifi", "parking"] + }' +``` + +### Error Responses + +Errors follow WordPress REST API conventions: + +```json +{ + "code": "rest_not_found", + "message": "Room not found.", + "data": { + "status": 404 + } +} +``` + +Common error codes: + +- `rest_invalid_param` (400): Invalid request parameters +- `rest_forbidden` (403): Insufficient permissions +- `rest_not_found` (404): Resource not found +- `rest_conflict` (409): Booking conflict +- `rest_rate_limit_exceeded` (429): Rate limit exceeded + ## Frequently Asked Questions ### Do I need a license to use this plugin? diff --git a/src/Api/Controllers/AbstractController.php b/src/Api/Controllers/AbstractController.php new file mode 100644 index 0000000..38affec --- /dev/null +++ b/src/Api/Controllers/AbstractController.php @@ -0,0 +1,382 @@ +rate_limiter = new RateLimiter(); + $this->formatter = new ResponseFormatter(); + } + + /** + * Check rate limit before processing request. + * + * @param WP_REST_Request $request Current request. + * @return WP_Error|null Error if rate limited, null otherwise. + */ + protected function check_rate_limit( WP_REST_Request $request ): ?WP_Error { + // Skip rate limiting if disabled. + if ( 'yes' !== get_option( 'wp_bnb_api_rate_limiting', 'yes' ) ) { + return null; + } + + $identifier = $this->get_client_identifier( $request ); + $endpoint = $request->get_route(); + + if ( ! $this->rate_limiter->check( $identifier, $endpoint ) ) { + return $this->formatter->rate_limit_error( + $this->rate_limiter->get_retry_after( $identifier, $endpoint ) + ); + } + + return null; + } + + /** + * Add rate limit headers to response. + * + * @param WP_REST_Response $response Current response. + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response Response with headers. + */ + protected function add_rate_limit_headers( WP_REST_Response $response, WP_REST_Request $request ): WP_REST_Response { + if ( 'yes' !== get_option( 'wp_bnb_api_rate_limiting', 'yes' ) ) { + return $response; + } + + $identifier = $this->get_client_identifier( $request ); + $endpoint = $request->get_route(); + $info = $this->rate_limiter->get_rate_limit_info( $identifier, $endpoint ); + + $response->header( 'X-RateLimit-Limit', (string) $info['limit'] ); + $response->header( 'X-RateLimit-Remaining', (string) $info['remaining'] ); + $response->header( 'X-RateLimit-Reset', (string) $info['reset'] ); + + return $response; + } + + /** + * Get client identifier for rate limiting. + * + * @param WP_REST_Request $request Current request. + * @return string Client identifier. + */ + protected function get_client_identifier( WP_REST_Request $request ): string { + // Use user ID if authenticated. + $user_id = get_current_user_id(); + if ( $user_id > 0 ) { + return 'user_' . $user_id; + } + + return 'ip_' . $this->get_client_ip(); + } + + /** + * Get client IP address. + * + * Supports proxies and Cloudflare. + * + * @return string Client IP address. + */ + protected function get_client_ip(): string { + $headers = array( + 'HTTP_CF_CONNECTING_IP', // Cloudflare. + 'HTTP_X_FORWARDED_FOR', // Proxy. + 'HTTP_X_REAL_IP', // Nginx. + 'REMOTE_ADDR', + ); + + foreach ( $headers as $header ) { + if ( ! empty( $_SERVER[ $header ] ) ) { + $ip = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ); + // Handle comma-separated list (X-Forwarded-For). + if ( str_contains( $ip, ',' ) ) { + $ip = trim( explode( ',', $ip )[0] ); + } + return $ip; + } + } + + return '127.0.0.1'; + } + + /** + * Validate date format (Y-m-d). + * + * @param string $date Date string. + * @return bool True if valid. + */ + protected function validate_date( string $date ): bool { + $d = \DateTimeImmutable::createFromFormat( 'Y-m-d', $date ); + return $d && $d->format( 'Y-m-d' ) === $date; + } + + /** + * Validate date is not in the past. + * + * @param string $date Date string (Y-m-d). + * @return bool True if date is today or future. + */ + protected function validate_future_date( string $date ): bool { + if ( ! $this->validate_date( $date ) ) { + return false; + } + $date_obj = \DateTimeImmutable::createFromFormat( 'Y-m-d', $date ); + $today = new \DateTimeImmutable( 'today' ); + return $date_obj >= $today; + } + + /** + * Permission callback for public endpoints. + * + * @return bool Always true. + */ + public function public_permission(): bool { + return true; + } + + /** + * Permission callback for authenticated endpoints. + * + * @return bool True if logged in. + */ + public function authenticated_permission(): bool { + return is_user_logged_in(); + } + + /** + * Permission callback for admin endpoints. + * + * @return bool True if user can edit posts. + */ + public function admin_permission(): bool { + return current_user_can( 'edit_posts' ); + } + + /** + * Permission callback for managing bookings. + * + * @return bool True if user can edit posts. + */ + public function manage_bookings_permission(): bool { + return current_user_can( 'edit_posts' ); + } + + /** + * Get pagination parameters from request. + * + * @param WP_REST_Request $request Current request. + * @return array{page: int, per_page: int, offset: int} + */ + protected function get_pagination_params( WP_REST_Request $request ): array { + $page = max( 1, (int) $request->get_param( 'page' ) ?: 1 ); + $per_page = min( 100, max( 1, (int) $request->get_param( 'per_page' ) ?: 10 ) ); + $offset = ( $page - 1 ) * $per_page; + + return array( + 'page' => $page, + 'per_page' => $per_page, + 'offset' => $offset, + ); + } + + /** + * Get sorting parameters from request. + * + * @param WP_REST_Request $request Current request. + * @param array $allowed_orderby Allowed orderby values. + * @param string $default_orderby Default orderby value. + * @return array{orderby: string, order: string} + */ + protected function get_sorting_params( WP_REST_Request $request, array $allowed_orderby = array( 'title', 'date' ), string $default_orderby = 'title' ): array { + $orderby = $request->get_param( 'orderby' ) ?: $default_orderby; + $order = strtoupper( $request->get_param( 'order' ) ?: 'ASC' ); + + // Validate orderby. + if ( ! in_array( $orderby, $allowed_orderby, true ) ) { + $orderby = $default_orderby; + } + + // Validate order. + if ( ! in_array( $order, array( 'ASC', 'DESC' ), true ) ) { + $order = 'ASC'; + } + + return array( + 'orderby' => $orderby, + 'order' => $order, + ); + } + + /** + * Format post for API response. + * + * @param \WP_Post $post Post object. + * @return array Basic post data. + */ + protected function format_post_base( \WP_Post $post ): array { + return array( + 'id' => $post->ID, + 'title' => get_the_title( $post ), + 'slug' => $post->post_name, + 'excerpt' => get_the_excerpt( $post ), + 'content' => apply_filters( 'the_content', $post->post_content ), + ); + } + + /** + * Format featured image for API response. + * + * @param int $post_id Post ID. + * @return array|null Image data or null. + */ + protected function format_featured_image( int $post_id ): ?array { + $thumbnail_id = get_post_thumbnail_id( $post_id ); + if ( ! $thumbnail_id ) { + return null; + } + + return $this->format_image( $thumbnail_id ); + } + + /** + * Format image attachment for API response. + * + * @param int $attachment_id Attachment ID. + * @return array|null Image data or null. + */ + protected function format_image( int $attachment_id ): ?array { + $full = wp_get_attachment_image_src( $attachment_id, 'full' ); + if ( ! $full ) { + return null; + } + + $sizes = array(); + foreach ( array( 'thumbnail', 'medium', 'large' ) as $size ) { + $src = wp_get_attachment_image_src( $attachment_id, $size ); + if ( $src ) { + $sizes[ $size ] = array( + 'url' => $src[0], + 'width' => $src[1], + 'height' => $src[2], + ); + } + } + + return array( + 'id' => $attachment_id, + 'url' => $full[0], + 'width' => $full[1], + 'height' => $full[2], + 'alt' => get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ), + 'sizes' => $sizes, + ); + } + + /** + * Add HATEOAS links to response item. + * + * @param array $item Response item. + * @param string $route Base route for self link. + * @param int $id Item ID. + * @return array Item with _links. + */ + protected function add_links( array $item, string $route, int $id ): array { + $item['_links'] = array( + 'self' => array( + array( + 'href' => rest_url( $this->namespace . '/' . $route . '/' . $id ), + ), + ), + ); + return $item; + } + + /** + * Get common collection parameters for schema. + * + * @return array Collection parameters. + */ + public function get_collection_params(): array { + return array( + 'page' => array( + 'description' => __( 'Current page of the collection.', 'wp-bnb' ), + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + 'sanitize_callback' => 'absint', + ), + 'per_page' => array( + 'description' => __( 'Maximum number of items to be returned per page.', 'wp-bnb' ), + 'type' => 'integer', + 'default' => 10, + 'minimum' => 1, + 'maximum' => 100, + 'sanitize_callback' => 'absint', + ), + 'search' => array( + 'description' => __( 'Limit results to those matching a string.', 'wp-bnb' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'orderby' => array( + 'description' => __( 'Sort collection by attribute.', 'wp-bnb' ), + 'type' => 'string', + 'default' => 'title', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'order' => array( + 'description' => __( 'Order sort attribute ascending or descending.', 'wp-bnb' ), + 'type' => 'string', + 'default' => 'asc', + 'enum' => array( 'asc', 'desc' ), + 'sanitize_callback' => 'sanitize_text_field', + ), + ); + } +} diff --git a/src/Api/Controllers/BookingsController.php b/src/Api/Controllers/BookingsController.php new file mode 100644 index 0000000..a8e9915 --- /dev/null +++ b/src/Api/Controllers/BookingsController.php @@ -0,0 +1,930 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'admin_permission' ), + 'args' => $this->get_bookings_collection_params(), + ), + ) + ); + + // POST /bookings - Create booking (public). + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this, 'public_permission' ), + 'args' => $this->get_create_booking_params(), + ), + ) + ); + + // GET /bookings/{id} - Get single booking. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'admin_permission' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Booking ID.', 'wp-bnb' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + ), + ), + ) + ); + + // PATCH /bookings/{id} - Update booking (admin). + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + array( + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_item' ), + 'permission_callback' => array( $this, 'admin_permission' ), + 'args' => $this->get_update_booking_params(), + ), + ) + ); + + // DELETE /bookings/{id} - Cancel booking (admin). + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + array( + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_item' ), + 'permission_callback' => array( $this, 'admin_permission' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Booking ID.', 'wp-bnb' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + ), + ), + ) + ); + + // POST /bookings/{id}/confirm - Confirm booking. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)/confirm', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'confirm_booking' ), + 'permission_callback' => array( $this, 'admin_permission' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Booking ID.', 'wp-bnb' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + ), + ), + ) + ); + + // POST /bookings/{id}/check-in - Check in guest. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)/check-in', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'check_in_booking' ), + 'permission_callback' => array( $this, 'admin_permission' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Booking ID.', 'wp-bnb' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + ), + ), + ) + ); + + // POST /bookings/{id}/check-out - Check out guest. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)/check-out', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'check_out_booking' ), + 'permission_callback' => array( $this, 'admin_permission' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Booking ID.', 'wp-bnb' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + ), + ), + ) + ); + } + + /** + * Get collection of bookings. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function get_items( $request ) { + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $pagination = $this->get_pagination_params( $request ); + + $args = array( + 'post_type' => Booking::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => $pagination['per_page'], + 'offset' => $pagination['offset'], + 'orderby' => 'date', + 'order' => 'DESC', + ); + + $meta_query = array(); + + // Status filter. + $status = $request->get_param( 'status' ); + if ( $status ) { + $meta_query[] = array( + 'key' => '_bnb_booking_status', + 'value' => $status, + ); + } + + // Room filter. + $room_id = $request->get_param( 'room_id' ); + if ( $room_id ) { + $meta_query[] = array( + 'key' => '_bnb_booking_room_id', + 'value' => $room_id, + ); + } + + // Guest filter. + $guest_id = $request->get_param( 'guest_id' ); + if ( $guest_id ) { + $meta_query[] = array( + 'key' => '_bnb_booking_guest_id', + 'value' => $guest_id, + ); + } + + // Date range filter. + $date_from = $request->get_param( 'date_from' ); + if ( $date_from && $this->validate_date( $date_from ) ) { + $meta_query[] = array( + 'key' => '_bnb_booking_check_in', + 'value' => $date_from, + 'compare' => '>=', + 'type' => 'DATE', + ); + } + + $date_to = $request->get_param( 'date_to' ); + if ( $date_to && $this->validate_date( $date_to ) ) { + $meta_query[] = array( + 'key' => '_bnb_booking_check_in', + 'value' => $date_to, + 'compare' => '<=', + 'type' => 'DATE', + ); + } + + if ( ! empty( $meta_query ) ) { + $meta_query['relation'] = 'AND'; + $args['meta_query'] = $meta_query; + } + + $query = new \WP_Query( $args ); + $items = array(); + + foreach ( $query->posts as $post ) { + $items[] = $this->prepare_booking_response( $post ); + } + + $response = $this->formatter->collection( + $items, + $query->found_posts, + $pagination['page'], + $pagination['per_page'] + ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Create a booking. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function create_item( $request ) { + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $room_id = $request->get_param( 'room_id' ); + $check_in = $request->get_param( 'check_in' ); + $check_out = $request->get_param( 'check_out' ); + $guest = $request->get_param( 'guest' ); + + // Validate room. + $room = get_post( $room_id ); + if ( ! $room || Room::POST_TYPE !== $room->post_type || 'publish' !== $room->post_status ) { + return $this->formatter->not_found( __( 'Room', 'wp-bnb' ) ); + } + + // Validate dates. + if ( ! $this->validate_date( $check_in ) ) { + return $this->formatter->validation_error( 'check_in', __( 'Invalid check-in date format. Use Y-m-d.', 'wp-bnb' ) ); + } + if ( ! $this->validate_date( $check_out ) ) { + return $this->formatter->validation_error( 'check_out', __( 'Invalid check-out date format. Use Y-m-d.', 'wp-bnb' ) ); + } + if ( $check_in >= $check_out ) { + return $this->formatter->validation_error( 'check_out', __( 'Check-out must be after check-in.', 'wp-bnb' ) ); + } + + // Check availability. + if ( ! Availability::is_available( $room_id, $check_in, $check_out ) ) { + return $this->formatter->conflict( + __( 'Room is not available for the selected dates.', 'wp-bnb' ) + ); + } + + // Validate guest info. + if ( empty( $guest['first_name'] ) || empty( $guest['last_name'] ) || empty( $guest['email'] ) ) { + return $this->formatter->validation_error( 'guest', __( 'Guest first name, last name, and email are required.', 'wp-bnb' ) ); + } + + if ( ! is_email( $guest['email'] ) ) { + return $this->formatter->validation_error( 'guest.email', __( 'Invalid email address.', 'wp-bnb' ) ); + } + + // Find or create guest. + $guest_id = $this->find_or_create_guest( $guest ); + + // Calculate price. + $price = Calculator::calculate( $room_id, $check_in, $check_out ); + $services = $request->get_param( 'services' ) ?? array(); + $room_price = $price['price'] ?? 0; + + // Calculate services total. + $services_total = 0; + $services_data = array(); + $check_in_date = new \DateTimeImmutable( $check_in ); + $check_out_date = new \DateTimeImmutable( $check_out ); + $nights = (int) $check_in_date->diff( $check_out_date )->days; + + foreach ( $services as $service_item ) { + $service_id = $service_item['service_id'] ?? 0; + $quantity = $service_item['quantity'] ?? 1; + + $service_data = Service::get_service_data( $service_id ); + if ( $service_data ) { + $service_price = Service::calculate_service_price( $service_id, $quantity, $nights ); + $services_total += $service_price; + $services_data[] = array( + 'service_id' => $service_id, + 'quantity' => $quantity, + 'price' => $service_price, + 'pricing_type' => $service_data['pricing_type'], + ); + } + } + + $total_price = $room_price + $services_total; + + // Generate reference. + $reference = Booking::generate_reference(); + + // Create booking post. + $guest_name = trim( $guest['first_name'] . ' ' . $guest['last_name'] ); + $post_id = wp_insert_post( + array( + 'post_type' => Booking::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => $guest_name . ' (' . $check_in . ' - ' . $check_out . ')', + ) + ); + + if ( is_wp_error( $post_id ) ) { + return $this->formatter->server_error( __( 'Failed to create booking.', 'wp-bnb' ) ); + } + + // Save meta. + update_post_meta( $post_id, '_bnb_booking_room_id', $room_id ); + update_post_meta( $post_id, '_bnb_booking_guest_id', $guest_id ); + update_post_meta( $post_id, '_bnb_booking_guest_name', $guest_name ); + update_post_meta( $post_id, '_bnb_booking_guest_email', sanitize_email( $guest['email'] ) ); + update_post_meta( $post_id, '_bnb_booking_guest_phone', sanitize_text_field( $guest['phone'] ?? '' ) ); + update_post_meta( $post_id, '_bnb_booking_check_in', $check_in ); + update_post_meta( $post_id, '_bnb_booking_check_out', $check_out ); + update_post_meta( $post_id, '_bnb_booking_status', 'pending' ); + update_post_meta( $post_id, '_bnb_booking_adults', absint( $request->get_param( 'guests_count' ) ?? 1 ) ); + update_post_meta( $post_id, '_bnb_booking_calculated_price', $room_price ); + update_post_meta( $post_id, '_bnb_booking_total_price', $total_price ); + update_post_meta( $post_id, '_bnb_booking_reference', $reference ); + + if ( ! empty( $services_data ) ) { + update_post_meta( $post_id, '_bnb_booking_services', wp_json_encode( $services_data ) ); + } + + $notes = $request->get_param( 'notes' ); + if ( $notes ) { + update_post_meta( $post_id, '_bnb_booking_guest_notes', sanitize_textarea_field( $notes ) ); + } + + // Send notification email. + if ( class_exists( EmailNotifier::class ) ) { + EmailNotifier::send_admin_notification( $post_id ); + } + + // Prepare response. + $booking = get_post( $post_id ); + $data = $this->prepare_booking_response( $booking, true ); + $location = rest_url( $this->namespace . '/bookings/' . $post_id ); + + $response = $this->formatter->created( $data, $location ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Get single booking. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function get_item( $request ) { + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $id = $request->get_param( 'id' ); + $post = get_post( $id ); + + if ( ! $post || Booking::POST_TYPE !== $post->post_type ) { + return $this->formatter->not_found( __( 'Booking', 'wp-bnb' ) ); + } + + $data = $this->prepare_booking_response( $post, true ); + $response = $this->formatter->success( $data ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Update a booking. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function update_item( $request ) { + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $id = $request->get_param( 'id' ); + $post = get_post( $id ); + + if ( ! $post || Booking::POST_TYPE !== $post->post_type ) { + return $this->formatter->not_found( __( 'Booking', 'wp-bnb' ) ); + } + + // Update status if provided. + $status = $request->get_param( 'status' ); + if ( $status ) { + $current_status = get_post_meta( $id, '_bnb_booking_status', true ); + if ( ! Booking::can_transition_to( $current_status, $status ) ) { + return $this->formatter->validation_error( + 'status', + sprintf( + /* translators: %1$s: current status, %2$s: target status */ + __( 'Cannot transition from %1$s to %2$s.', 'wp-bnb' ), + $current_status, + $status + ) + ); + } + update_post_meta( $id, '_bnb_booking_status', $status ); + + if ( 'confirmed' === $status ) { + update_post_meta( $id, '_bnb_booking_confirmed_at', current_time( 'mysql' ) ); + } + } + + // Update notes if provided. + $notes = $request->get_param( 'notes' ); + if ( null !== $notes ) { + update_post_meta( $id, '_bnb_booking_notes', sanitize_textarea_field( $notes ) ); + } + + // Update guest notes if provided. + $guest_notes = $request->get_param( 'guest_notes' ); + if ( null !== $guest_notes ) { + update_post_meta( $id, '_bnb_booking_guest_notes', sanitize_textarea_field( $guest_notes ) ); + } + + $booking = get_post( $id ); + $data = $this->prepare_booking_response( $booking, true ); + $response = $this->formatter->success( $data ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Cancel a booking. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function delete_item( $request ) { + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $id = $request->get_param( 'id' ); + $post = get_post( $id ); + + if ( ! $post || Booking::POST_TYPE !== $post->post_type ) { + return $this->formatter->not_found( __( 'Booking', 'wp-bnb' ) ); + } + + // Cancel the booking (don't delete). + update_post_meta( $id, '_bnb_booking_status', 'cancelled' ); + + // Send cancellation email. + if ( class_exists( EmailNotifier::class ) ) { + EmailNotifier::send_cancellation_email( $id ); + } + + return $this->formatter->no_content(); + } + + /** + * Confirm a booking. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function confirm_booking( $request ) { + return $this->transition_status( $request, 'confirmed' ); + } + + /** + * Check in a booking. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function check_in_booking( $request ) { + return $this->transition_status( $request, 'checked_in' ); + } + + /** + * Check out a booking. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function check_out_booking( $request ) { + return $this->transition_status( $request, 'checked_out' ); + } + + /** + * Transition booking status. + * + * @param WP_REST_Request $request Current request. + * @param string $new_status Target status. + * @return WP_REST_Response|WP_Error Response object or error. + */ + private function transition_status( WP_REST_Request $request, string $new_status ) { + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $id = $request->get_param( 'id' ); + $post = get_post( $id ); + + if ( ! $post || Booking::POST_TYPE !== $post->post_type ) { + return $this->formatter->not_found( __( 'Booking', 'wp-bnb' ) ); + } + + $current_status = get_post_meta( $id, '_bnb_booking_status', true ); + + if ( ! Booking::can_transition_to( $current_status, $new_status ) ) { + return $this->formatter->validation_error( + 'status', + sprintf( + /* translators: %1$s: current status, %2$s: target status */ + __( 'Cannot transition from %1$s to %2$s.', 'wp-bnb' ), + $current_status, + $new_status + ) + ); + } + + update_post_meta( $id, '_bnb_booking_status', $new_status ); + + if ( 'confirmed' === $new_status ) { + update_post_meta( $id, '_bnb_booking_confirmed_at', current_time( 'mysql' ) ); + if ( class_exists( EmailNotifier::class ) ) { + EmailNotifier::send_confirmation_email( $id ); + } + } + + $booking = get_post( $id ); + $data = $this->prepare_booking_response( $booking, true ); + $response = $this->formatter->success( $data ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Find or create a guest from booking data. + * + * @param array $guest_data Guest data. + * @return int Guest post ID. + */ + private function find_or_create_guest( array $guest_data ): int { + $email = sanitize_email( $guest_data['email'] ); + + // Check if guest exists. + $existing = Guest::get_by_email( $email ); + if ( $existing ) { + return $existing->ID; + } + + // Create new guest. + $guest_name = trim( $guest_data['first_name'] . ' ' . $guest_data['last_name'] ); + $guest_id = wp_insert_post( + array( + 'post_type' => Guest::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => $guest_name, + ) + ); + + if ( is_wp_error( $guest_id ) ) { + return 0; + } + + // Save guest meta. + update_post_meta( $guest_id, '_bnb_guest_first_name', sanitize_text_field( $guest_data['first_name'] ) ); + update_post_meta( $guest_id, '_bnb_guest_last_name', sanitize_text_field( $guest_data['last_name'] ) ); + update_post_meta( $guest_id, '_bnb_guest_email', $email ); + update_post_meta( $guest_id, '_bnb_guest_status', 'active' ); + + if ( ! empty( $guest_data['phone'] ) ) { + update_post_meta( $guest_id, '_bnb_guest_phone', sanitize_text_field( $guest_data['phone'] ) ); + } + + if ( ! empty( $guest_data['address'] ) ) { + $address = $guest_data['address']; + if ( ! empty( $address['street'] ) ) { + update_post_meta( $guest_id, '_bnb_guest_street', sanitize_text_field( $address['street'] ) ); + } + if ( ! empty( $address['city'] ) ) { + update_post_meta( $guest_id, '_bnb_guest_city', sanitize_text_field( $address['city'] ) ); + } + if ( ! empty( $address['postal_code'] ) ) { + update_post_meta( $guest_id, '_bnb_guest_postal_code', sanitize_text_field( $address['postal_code'] ) ); + } + if ( ! empty( $address['country'] ) ) { + update_post_meta( $guest_id, '_bnb_guest_country', sanitize_text_field( $address['country'] ) ); + } + } + + return $guest_id; + } + + /** + * Prepare booking data for response. + * + * @param \WP_Post $post Booking post object. + * @param bool $full Include full details. + * @return array Booking data. + */ + private function prepare_booking_response( \WP_Post $post, bool $full = false ): array { + $room_id = get_post_meta( $post->ID, '_bnb_booking_room_id', true ); + $guest_id = get_post_meta( $post->ID, '_bnb_booking_guest_id', true ); + $check_in = get_post_meta( $post->ID, '_bnb_booking_check_in', true ); + $check_out = get_post_meta( $post->ID, '_bnb_booking_check_out', true ); + $status = get_post_meta( $post->ID, '_bnb_booking_status', true ); + + // Calculate nights. + $nights = 0; + if ( $check_in && $check_out ) { + $check_in_date = new \DateTimeImmutable( $check_in ); + $check_out_date = new \DateTimeImmutable( $check_out ); + $nights = (int) $check_in_date->diff( $check_out_date )->days; + } + + $room = $room_id ? get_post( $room_id ) : null; + + $data = array( + 'id' => $post->ID, + 'reference' => get_post_meta( $post->ID, '_bnb_booking_reference', true ) ?: $post->post_title, + 'status' => $status, + 'room' => $room ? array( + 'id' => $room->ID, + 'title' => get_the_title( $room ), + 'room_number' => get_post_meta( $room->ID, '_bnb_room_room_number', true ), + ) : null, + 'guest' => array( + 'id' => (int) $guest_id, + 'name' => get_post_meta( $post->ID, '_bnb_booking_guest_name', true ), + 'email' => get_post_meta( $post->ID, '_bnb_booking_guest_email', true ), + 'phone' => get_post_meta( $post->ID, '_bnb_booking_guest_phone', true ), + ), + 'dates' => array( + 'check_in' => $check_in, + 'check_out' => $check_out, + 'nights' => $nights, + ), + 'pricing' => array( + 'room_total' => (float) get_post_meta( $post->ID, '_bnb_booking_calculated_price', true ), + 'services_total' => 0, + 'grand_total' => (float) get_post_meta( $post->ID, '_bnb_booking_total_price', true ), + 'currency' => get_option( 'wp_bnb_currency', 'CHF' ), + ), + 'created_at' => $post->post_date_gmt, + ); + + // Get building info. + if ( $room ) { + $building_id = get_post_meta( $room->ID, '_bnb_room_building_id', true ); + $building = $building_id ? get_post( $building_id ) : null; + if ( $building ) { + $data['building'] = array( + 'id' => $building->ID, + 'title' => get_the_title( $building ), + ); + } + } + + // Calculate services total. + $services_json = get_post_meta( $post->ID, '_bnb_booking_services', true ); + if ( $services_json ) { + $services = json_decode( $services_json, true ); + if ( is_array( $services ) ) { + $services_total = 0; + $services_list = array(); + foreach ( $services as $service ) { + $services_total += (float) ( $service['price'] ?? 0 ); + $service_post = get_post( $service['service_id'] ); + $services_list[] = array( + 'id' => $service['service_id'], + 'name' => $service_post ? get_the_title( $service_post ) : '', + 'quantity' => $service['quantity'] ?? 1, + 'price' => (float) ( $service['price'] ?? 0 ), + ); + } + $data['pricing']['services_total'] = $services_total; + $data['services'] = $services_list; + } + } + + if ( $full ) { + $data['notes'] = get_post_meta( $post->ID, '_bnb_booking_notes', true ); + $data['guest_notes'] = get_post_meta( $post->ID, '_bnb_booking_guest_notes', true ); + $data['adults'] = (int) get_post_meta( $post->ID, '_bnb_booking_adults', true ); + $data['children'] = (int) get_post_meta( $post->ID, '_bnb_booking_children', true ); + $data['confirmed_at'] = get_post_meta( $post->ID, '_bnb_booking_confirmed_at', true ); + } + + $data['_links'] = array( + 'self' => array( + array( 'href' => rest_url( $this->namespace . '/bookings/' . $post->ID ) ), + ), + 'room' => $room ? array( + array( 'href' => rest_url( $this->namespace . '/rooms/' . $room->ID ) ), + ) : array(), + ); + + return $data; + } + + /** + * Get bookings collection parameters. + * + * @return array Collection parameters. + */ + private function get_bookings_collection_params(): array { + $params = $this->get_collection_params(); + + $params['status'] = array( + 'description' => __( 'Filter by booking status.', 'wp-bnb' ), + 'type' => 'string', + 'enum' => array( 'pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled' ), + 'sanitize_callback' => 'sanitize_text_field', + ); + + $params['room_id'] = array( + 'description' => __( 'Filter by room ID.', 'wp-bnb' ), + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ); + + $params['guest_id'] = array( + 'description' => __( 'Filter by guest ID.', 'wp-bnb' ), + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ); + + $params['date_from'] = array( + 'description' => __( 'Filter bookings with check-in from this date (Y-m-d).', 'wp-bnb' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ); + + $params['date_to'] = array( + 'description' => __( 'Filter bookings with check-in until this date (Y-m-d).', 'wp-bnb' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ); + + return $params; + } + + /** + * Get create booking parameters. + * + * @return array Create parameters. + */ + private function get_create_booking_params(): array { + return array( + 'room_id' => array( + 'description' => __( 'Room ID.', 'wp-bnb' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + 'check_in' => array( + 'description' => __( 'Check-in date (Y-m-d).', 'wp-bnb' ), + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'check_out' => array( + 'description' => __( 'Check-out date (Y-m-d).', 'wp-bnb' ), + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'guest' => array( + 'description' => __( 'Guest information.', 'wp-bnb' ), + 'type' => 'object', + 'required' => true, + 'properties' => array( + 'first_name' => array( 'type' => 'string', 'required' => true ), + 'last_name' => array( 'type' => 'string', 'required' => true ), + 'email' => array( 'type' => 'string', 'required' => true, 'format' => 'email' ), + 'phone' => array( 'type' => 'string' ), + 'address' => array( + 'type' => 'object', + 'properties' => array( + 'street' => array( 'type' => 'string' ), + 'city' => array( 'type' => 'string' ), + 'postal_code' => array( 'type' => 'string' ), + 'country' => array( 'type' => 'string' ), + ), + ), + ), + ), + 'guests_count' => array( + 'description' => __( 'Number of guests.', 'wp-bnb' ), + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + 'sanitize_callback' => 'absint', + ), + 'services' => array( + 'description' => __( 'Additional services.', 'wp-bnb' ), + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'service_id' => array( 'type' => 'integer', 'required' => true ), + 'quantity' => array( 'type' => 'integer', 'default' => 1 ), + ), + ), + ), + 'notes' => array( + 'description' => __( 'Guest notes or special requests.', 'wp-bnb' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_textarea_field', + ), + ); + } + + /** + * Get update booking parameters. + * + * @return array Update parameters. + */ + private function get_update_booking_params(): array { + return array( + 'id' => array( + 'description' => __( 'Booking ID.', 'wp-bnb' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + 'status' => array( + 'description' => __( 'Booking status.', 'wp-bnb' ), + 'type' => 'string', + 'enum' => array( 'pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled' ), + 'sanitize_callback' => 'sanitize_text_field', + ), + 'notes' => array( + 'description' => __( 'Internal staff notes.', 'wp-bnb' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_textarea_field', + ), + 'guest_notes' => array( + 'description' => __( 'Guest notes or special requests.', 'wp-bnb' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_textarea_field', + ), + ); + } +} diff --git a/src/Api/Controllers/BuildingsController.php b/src/Api/Controllers/BuildingsController.php new file mode 100644 index 0000000..f799df0 --- /dev/null +++ b/src/Api/Controllers/BuildingsController.php @@ -0,0 +1,323 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'public_permission' ), + 'args' => $this->get_collection_params(), + ), + ) + ); + + // GET /buildings/{id} - Get single building. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'public_permission' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Building ID.', 'wp-bnb' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + ), + ), + ) + ); + + // GET /buildings/{id}/rooms - Get rooms in building. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)/rooms', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_building_rooms' ), + 'permission_callback' => array( $this, 'public_permission' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Building ID.', 'wp-bnb' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + 'status' => array( + 'description' => __( 'Filter by room status.', 'wp-bnb' ), + 'type' => 'string', + 'enum' => array( 'available', 'occupied', 'maintenance', 'blocked' ), + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ), + ) + ); + } + + /** + * Get collection of buildings. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function get_items( $request ) { + // Check rate limit. + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $pagination = $this->get_pagination_params( $request ); + $sorting = $this->get_sorting_params( $request, array( 'title', 'date' ), 'title' ); + + $args = array( + 'post_type' => Building::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => $pagination['per_page'], + 'offset' => $pagination['offset'], + 'orderby' => $sorting['orderby'], + 'order' => $sorting['order'], + ); + + // Search filter. + $search = $request->get_param( 'search' ); + if ( $search ) { + $args['s'] = $search; + } + + $query = new \WP_Query( $args ); + $items = array(); + + foreach ( $query->posts as $post ) { + $items[] = $this->prepare_building_response( $post ); + } + + $response = $this->formatter->collection( + $items, + $query->found_posts, + $pagination['page'], + $pagination['per_page'] + ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Get single building. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function get_item( $request ) { + // Check rate limit. + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $id = $request->get_param( 'id' ); + $post = get_post( $id ); + + if ( ! $post || Building::POST_TYPE !== $post->post_type || 'publish' !== $post->post_status ) { + return $this->formatter->not_found( __( 'Building', 'wp-bnb' ) ); + } + + $data = $this->prepare_building_response( $post, true ); + $response = $this->formatter->success( $data ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Get rooms in a building. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function get_building_rooms( $request ) { + // Check rate limit. + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $building_id = $request->get_param( 'id' ); + $building = get_post( $building_id ); + + if ( ! $building || Building::POST_TYPE !== $building->post_type || 'publish' !== $building->post_status ) { + return $this->formatter->not_found( __( 'Building', 'wp-bnb' ) ); + } + + $args = array( + 'post_type' => Room::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'meta_query' => array( + array( + 'key' => '_bnb_room_building_id', + 'value' => $building_id, + ), + ), + 'orderby' => 'meta_value', + 'meta_key' => '_bnb_room_room_number', + 'order' => 'ASC', + ); + + // Filter by status. + $status = $request->get_param( 'status' ); + if ( $status ) { + $args['meta_query'][] = array( + 'key' => '_bnb_room_status', + 'value' => $status, + ); + } + + $rooms = get_posts( $args ); + $items = array(); + + foreach ( $rooms as $room ) { + $items[] = $this->prepare_room_summary( $room ); + } + + $response = $this->formatter->success( $items ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Prepare building data for response. + * + * @param \WP_Post $post Building post object. + * @param bool $full Include full details. + * @return array Building data. + */ + private function prepare_building_response( \WP_Post $post, bool $full = false ): array { + $data = $this->format_post_base( $post ); + + // Featured image. + $data['featured_image'] = $this->format_featured_image( $post->ID ); + $data['permalink'] = get_permalink( $post->ID ); + + // Address. + $data['address'] = array( + 'street' => get_post_meta( $post->ID, '_bnb_building_street', true ), + 'street2' => get_post_meta( $post->ID, '_bnb_building_street2', true ), + 'city' => get_post_meta( $post->ID, '_bnb_building_city', true ), + 'state' => get_post_meta( $post->ID, '_bnb_building_state', true ), + 'postal_code' => get_post_meta( $post->ID, '_bnb_building_zip', true ), + 'country' => get_post_meta( $post->ID, '_bnb_building_country', true ), + ); + + // Contact. + $data['contact'] = array( + 'phone' => get_post_meta( $post->ID, '_bnb_building_phone', true ), + 'email' => get_post_meta( $post->ID, '_bnb_building_email', true ), + 'website' => get_post_meta( $post->ID, '_bnb_building_website', true ), + ); + + // Details. + $data['details'] = array( + 'rooms_count' => (int) get_post_meta( $post->ID, '_bnb_building_total_rooms', true ), + 'floors' => (int) get_post_meta( $post->ID, '_bnb_building_floors', true ), + 'year_built' => (int) get_post_meta( $post->ID, '_bnb_building_year_built', true ), + 'check_in_time' => get_post_meta( $post->ID, '_bnb_building_check_in_time', true ) ?: '14:00', + 'check_out_time' => get_post_meta( $post->ID, '_bnb_building_check_out_time', true ) ?: '11:00', + ); + + // Count actual rooms. + $actual_rooms = Room::get_rooms_for_building( $post->ID ); + $data['details']['actual_rooms_count'] = count( $actual_rooms ); + + // Full address formatted. + if ( $full ) { + $data['address']['formatted'] = Building::get_formatted_address( $post->ID ); + + // Country name. + $countries = Building::get_countries(); + $country_code = $data['address']['country']; + $data['address']['country_name'] = $countries[ $country_code ] ?? $country_code; + } + + // Add HATEOAS links. + $data['_links'] = array( + 'self' => array( + array( 'href' => rest_url( $this->namespace . '/buildings/' . $post->ID ) ), + ), + 'rooms' => array( + array( 'href' => rest_url( $this->namespace . '/buildings/' . $post->ID . '/rooms' ) ), + ), + ); + + return $data; + } + + /** + * Prepare room summary for building rooms list. + * + * @param \WP_Post $room Room post object. + * @return array Room summary data. + */ + private function prepare_room_summary( \WP_Post $room ): array { + return array( + 'id' => $room->ID, + 'title' => get_the_title( $room ), + 'slug' => $room->post_name, + 'permalink' => get_permalink( $room->ID ), + 'room_number' => get_post_meta( $room->ID, '_bnb_room_room_number', true ), + 'floor' => (int) get_post_meta( $room->ID, '_bnb_room_floor', true ), + 'capacity' => (int) get_post_meta( $room->ID, '_bnb_room_capacity', true ), + 'status' => get_post_meta( $room->ID, '_bnb_room_status', true ) ?: 'available', + 'thumbnail' => get_the_post_thumbnail_url( $room->ID, 'thumbnail' ) ?: null, + '_links' => array( + 'self' => array( + array( 'href' => rest_url( $this->namespace . '/rooms/' . $room->ID ) ), + ), + ), + ); + } +} diff --git a/src/Api/Controllers/GuestsController.php b/src/Api/Controllers/GuestsController.php new file mode 100644 index 0000000..d4286f4 --- /dev/null +++ b/src/Api/Controllers/GuestsController.php @@ -0,0 +1,452 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'admin_permission' ), + 'args' => $this->get_collection_params(), + ), + ) + ); + + // GET /guests/search - Search guests (admin). + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/search', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'search_guests' ), + 'permission_callback' => array( $this, 'admin_permission' ), + 'args' => array( + 'q' => array( + 'description' => __( 'Search query (name, email, phone).', 'wp-bnb' ), + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'limit' => array( + 'description' => __( 'Maximum results.', 'wp-bnb' ), + 'type' => 'integer', + 'default' => 10, + 'minimum' => 1, + 'maximum' => 50, + 'sanitize_callback' => 'absint', + ), + ), + ), + ) + ); + + // GET /guests/{id} - Get single guest (admin). + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'admin_permission' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Guest ID.', 'wp-bnb' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + ), + ), + ) + ); + + // GET /guests/{id}/bookings - Get guest's bookings (admin). + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)/bookings', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_guest_bookings' ), + 'permission_callback' => array( $this, 'admin_permission' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Guest ID.', 'wp-bnb' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + ), + ), + ) + ); + } + + /** + * Get collection of guests. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function get_items( $request ) { + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $pagination = $this->get_pagination_params( $request ); + $sorting = $this->get_sorting_params( $request, array( 'title', 'date' ), 'title' ); + + $args = array( + 'post_type' => Guest::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => $pagination['per_page'], + 'offset' => $pagination['offset'], + 'orderby' => $sorting['orderby'], + 'order' => $sorting['order'], + ); + + // Search filter. + $search = $request->get_param( 'search' ); + if ( $search ) { + $args['s'] = $search; + } + + // Status filter. + $status = $request->get_param( 'status' ); + if ( $status ) { + $args['meta_query'] = array( + array( + 'key' => '_bnb_guest_status', + 'value' => $status, + ), + ); + } + + $query = new \WP_Query( $args ); + $items = array(); + + foreach ( $query->posts as $post ) { + $items[] = $this->prepare_guest_response( $post ); + } + + $response = $this->formatter->collection( + $items, + $query->found_posts, + $pagination['page'], + $pagination['per_page'] + ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Search guests by name, email, or phone. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function search_guests( $request ) { + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $query = $request->get_param( 'q' ); + $limit = $request->get_param( 'limit' ); + + // Search by title (name) first. + $args = array( + 'post_type' => Guest::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => $limit, + 's' => $query, + ); + + $results = get_posts( $args ); + + // Also search by email and phone if we have room for more results. + if ( count( $results ) < $limit ) { + $existing_ids = wp_list_pluck( $results, 'ID' ); + $remaining = $limit - count( $results ); + + // Search by email. + $email_args = array( + 'post_type' => Guest::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => $remaining, + 'post__not_in' => $existing_ids, + 'meta_query' => array( + array( + 'key' => '_bnb_guest_email', + 'value' => $query, + 'compare' => 'LIKE', + ), + ), + ); + $email_results = get_posts( $email_args ); + $results = array_merge( $results, $email_results ); + + // Search by phone if still room. + if ( count( $results ) < $limit ) { + $existing_ids = wp_list_pluck( $results, 'ID' ); + $remaining = $limit - count( $results ); + + $phone_args = array( + 'post_type' => Guest::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => $remaining, + 'post__not_in' => $existing_ids, + 'meta_query' => array( + array( + 'key' => '_bnb_guest_phone', + 'value' => $query, + 'compare' => 'LIKE', + ), + ), + ); + $phone_results = get_posts( $phone_args ); + $results = array_merge( $results, $phone_results ); + } + } + + $items = array(); + foreach ( $results as $post ) { + $items[] = $this->prepare_guest_summary( $post ); + } + + $response = $this->formatter->success( $items ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Get single guest. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function get_item( $request ) { + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $id = $request->get_param( 'id' ); + $post = get_post( $id ); + + if ( ! $post || Guest::POST_TYPE !== $post->post_type ) { + return $this->formatter->not_found( __( 'Guest', 'wp-bnb' ) ); + } + + $data = $this->prepare_guest_response( $post, true ); + $response = $this->formatter->success( $data ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Get guest's booking history. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function get_guest_bookings( $request ) { + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $guest_id = $request->get_param( 'id' ); + $guest = get_post( $guest_id ); + + if ( ! $guest || Guest::POST_TYPE !== $guest->post_type ) { + return $this->formatter->not_found( __( 'Guest', 'wp-bnb' ) ); + } + + $bookings = Guest::get_bookings( $guest_id ); + $items = array(); + + foreach ( $bookings as $booking ) { + $room_id = get_post_meta( $booking->ID, '_bnb_booking_room_id', true ); + $room = $room_id ? get_post( $room_id ) : null; + $check_in = get_post_meta( $booking->ID, '_bnb_booking_check_in', true ); + $check_out = get_post_meta( $booking->ID, '_bnb_booking_check_out', true ); + + $items[] = array( + 'id' => $booking->ID, + 'reference' => get_post_meta( $booking->ID, '_bnb_booking_reference', true ) ?: $booking->post_title, + 'room' => $room ? array( + 'id' => $room->ID, + 'title' => get_the_title( $room ), + ) : null, + 'check_in' => $check_in, + 'check_out' => $check_out, + 'status' => get_post_meta( $booking->ID, '_bnb_booking_status', true ), + 'total' => (float) get_post_meta( $booking->ID, '_bnb_booking_total_price', true ), + 'created_at' => $booking->post_date_gmt, + '_links' => array( + 'self' => array( + array( 'href' => rest_url( $this->namespace . '/bookings/' . $booking->ID ) ), + ), + ), + ); + } + + $response = $this->formatter->success( $items ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Prepare guest data for response. + * + * @param \WP_Post $post Guest post object. + * @param bool $full Include full details. + * @return array Guest data. + */ + private function prepare_guest_response( \WP_Post $post, bool $full = false ): array { + $data = array( + 'id' => $post->ID, + 'first_name' => get_post_meta( $post->ID, '_bnb_guest_first_name', true ), + 'last_name' => get_post_meta( $post->ID, '_bnb_guest_last_name', true ), + 'email' => get_post_meta( $post->ID, '_bnb_guest_email', true ), + 'phone' => get_post_meta( $post->ID, '_bnb_guest_phone', true ), + 'status' => get_post_meta( $post->ID, '_bnb_guest_status', true ) ?: 'active', + 'created_at' => $post->post_date_gmt, + ); + + // Address. + $data['address'] = array( + 'street' => get_post_meta( $post->ID, '_bnb_guest_street', true ), + 'city' => get_post_meta( $post->ID, '_bnb_guest_city', true ), + 'postal_code' => get_post_meta( $post->ID, '_bnb_guest_postal_code', true ), + 'country' => get_post_meta( $post->ID, '_bnb_guest_country', true ), + ); + + // Statistics. + $booking_count = Guest::get_booking_count( $post->ID ); + $total_spent = Guest::get_total_spent( $post->ID ); + + $data['statistics'] = array( + 'total_bookings' => $booking_count, + 'total_spent' => $total_spent, + ); + + if ( $full ) { + $data['nationality'] = get_post_meta( $post->ID, '_bnb_guest_nationality', true ); + $data['date_of_birth'] = get_post_meta( $post->ID, '_bnb_guest_date_of_birth', true ); + $data['notes'] = get_post_meta( $post->ID, '_bnb_guest_notes', true ); + $data['preferences'] = get_post_meta( $post->ID, '_bnb_guest_preferences', true ); + + // Get last stay date. + $bookings = Guest::get_bookings( $post->ID ); + if ( ! empty( $bookings ) ) { + $last_booking = $bookings[0]; + $data['statistics']['last_stay'] = get_post_meta( $last_booking->ID, '_bnb_booking_check_out', true ); + } + + // Formatted address. + $data['address']['formatted'] = Guest::get_formatted_address( $post->ID ); + + // GDPR consent info. + $data['consent'] = array( + 'data_processing' => (bool) get_post_meta( $post->ID, '_bnb_guest_consent_data', true ), + 'marketing' => (bool) get_post_meta( $post->ID, '_bnb_guest_consent_marketing', true ), + 'date' => get_post_meta( $post->ID, '_bnb_guest_consent_date', true ), + ); + } + + // Note: ID/passport numbers are NOT exposed via API for security. + + $data['_links'] = array( + 'self' => array( + array( 'href' => rest_url( $this->namespace . '/guests/' . $post->ID ) ), + ), + 'bookings' => array( + array( 'href' => rest_url( $this->namespace . '/guests/' . $post->ID . '/bookings' ) ), + ), + ); + + return $data; + } + + /** + * Prepare guest summary for search results. + * + * @param \WP_Post $post Guest post object. + * @return array Guest summary. + */ + private function prepare_guest_summary( \WP_Post $post ): array { + return array( + 'id' => $post->ID, + 'first_name' => get_post_meta( $post->ID, '_bnb_guest_first_name', true ), + 'last_name' => get_post_meta( $post->ID, '_bnb_guest_last_name', true ), + 'email' => get_post_meta( $post->ID, '_bnb_guest_email', true ), + 'phone' => get_post_meta( $post->ID, '_bnb_guest_phone', true ), + '_links' => array( + 'self' => array( + array( 'href' => rest_url( $this->namespace . '/guests/' . $post->ID ) ), + ), + ), + ); + } + + /** + * Get collection parameters with status filter. + * + * @return array Collection parameters. + */ + public function get_collection_params(): array { + $params = parent::get_collection_params(); + + $params['status'] = array( + 'description' => __( 'Filter by guest status.', 'wp-bnb' ), + 'type' => 'string', + 'enum' => array( 'active', 'inactive', 'blocked' ), + 'sanitize_callback' => 'sanitize_text_field', + ); + + return $params; + } +} diff --git a/src/Api/Controllers/PricingController.php b/src/Api/Controllers/PricingController.php new file mode 100644 index 0000000..f7cd6a4 --- /dev/null +++ b/src/Api/Controllers/PricingController.php @@ -0,0 +1,278 @@ +namespace, + '/' . $this->rest_base . '/calculate', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'calculate_price' ), + 'permission_callback' => array( $this, 'public_permission' ), + 'args' => array( + 'room_id' => array( + 'description' => __( 'Room ID.', 'wp-bnb' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + 'check_in' => array( + 'description' => __( 'Check-in date (Y-m-d).', 'wp-bnb' ), + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'check_out' => array( + 'description' => __( 'Check-out date (Y-m-d).', 'wp-bnb' ), + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'services' => array( + 'description' => __( 'Additional services.', 'wp-bnb' ), + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'service_id' => array( 'type' => 'integer', 'required' => true ), + 'quantity' => array( 'type' => 'integer', 'default' => 1 ), + ), + ), + ), + ), + ), + ) + ); + + // GET /pricing/seasons - Get active seasons. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/seasons', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_seasons' ), + 'permission_callback' => array( $this, 'public_permission' ), + ), + ) + ); + } + + /** + * Calculate full booking price. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function calculate_price( $request ) { + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $room_id = $request->get_param( 'room_id' ); + $check_in = $request->get_param( 'check_in' ); + $check_out = $request->get_param( 'check_out' ); + $services = $request->get_param( 'services' ) ?? array(); + + // Validate room. + $room = get_post( $room_id ); + if ( ! $room || Room::POST_TYPE !== $room->post_type || 'publish' !== $room->post_status ) { + return $this->formatter->not_found( __( 'Room', 'wp-bnb' ) ); + } + + // Validate dates. + if ( ! $this->validate_date( $check_in ) ) { + return $this->formatter->validation_error( 'check_in', __( 'Invalid check-in date format. Use Y-m-d.', 'wp-bnb' ) ); + } + if ( ! $this->validate_date( $check_out ) ) { + return $this->formatter->validation_error( 'check_out', __( 'Invalid check-out date format. Use Y-m-d.', 'wp-bnb' ) ); + } + if ( $check_in >= $check_out ) { + return $this->formatter->validation_error( 'check_out', __( 'Check-out must be after check-in.', 'wp-bnb' ) ); + } + + // Calculate nights. + $check_in_date = new \DateTimeImmutable( $check_in ); + $check_out_date = new \DateTimeImmutable( $check_out ); + $nights = (int) $check_in_date->diff( $check_out_date )->days; + + // Calculate room price. + $price = Calculator::calculate( $room_id, $check_in, $check_out ); + $room_total = $price['price'] ?? 0; + $breakdown = $price['breakdown'] ?? array(); + $currency = get_option( 'wp_bnb_currency', 'CHF' ); + + // Build night-by-night breakdown. + $night_breakdown = array(); + $current_date = $check_in_date; + $base_rate = $breakdown['base_price_per_night'] ?? 0; + + while ( $current_date < $check_out_date ) { + $date_str = $current_date->format( 'Y-m-d' ); + $day_of_week = (int) $current_date->format( 'w' ); + $modifiers = array(); + $rate = $base_rate; + + // Check for weekend surcharge. + $weekend_days = explode( ',', get_option( 'wp_bnb_weekend_days', '5,6' ) ); + if ( in_array( (string) $day_of_week, $weekend_days, true ) ) { + $weekend_surcharge = $breakdown['weekend_surcharge'] ?? 0; + if ( $weekend_surcharge > 0 ) { + $rate += $weekend_surcharge / max( 1, $breakdown['weekend_nights'] ?? 1 ); + $modifiers[] = 'weekend_surcharge'; + } + } + + // Check for seasonal modifier. + $season = Season::get_active_season( $date_str ); + if ( $season && $season['modifier'] != 1.0 ) { + $modifiers[] = 'season:' . $season['name']; + } + + $night_breakdown[] = array( + 'date' => $date_str, + 'rate' => round( $rate, 2 ), + 'modifiers' => $modifiers, + ); + + $current_date = $current_date->modify( '+1 day' ); + } + + // Calculate services. + $services_items = array(); + $services_total = 0; + + foreach ( $services as $service_item ) { + $service_id = $service_item['service_id'] ?? 0; + $quantity = $service_item['quantity'] ?? 1; + + $service_data = Service::get_service_data( $service_id ); + if ( $service_data && 'active' === $service_data['status'] ) { + $service_price = Service::calculate_service_price( $service_id, $quantity, $nights ); + $services_total += $service_price; + + $services_items[] = array( + 'id' => $service_id, + 'name' => $service_data['title'], + 'quantity' => $quantity, + 'nights' => 'per_night' === $service_data['pricing_type'] ? $nights : null, + 'subtotal' => $service_price, + ); + } + } + + $grand_total = $room_total + $services_total; + + // Build response. + $data = array( + 'room' => array( + 'id' => $room->ID, + 'title' => get_the_title( $room ), + 'pricing_tier' => $breakdown['tier']->value ?? 'short_term', + ), + 'dates' => array( + 'check_in' => $check_in, + 'check_out' => $check_out, + 'nights' => $nights, + ), + 'room_pricing' => array( + 'base_rate' => $breakdown['base_price_per_night'] ?? 0, + 'subtotal' => $room_total, + 'breakdown' => $night_breakdown, + ), + 'services_pricing' => array( + 'items' => $services_items, + 'subtotal' => $services_total, + ), + 'totals' => array( + 'room' => $room_total, + 'services' => $services_total, + 'grand_total' => $grand_total, + 'currency' => $currency, + 'formatted' => Calculator::formatPrice( $grand_total ), + ), + ); + + $response = $this->formatter->success( $data ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Get active seasons. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function get_seasons( $request ) { + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $seasons = Season::get_all(); + $items = array(); + + foreach ( $seasons as $season ) { + $items[] = array( + 'id' => $season['id'], + 'name' => $season['name'], + 'start_date' => $season['start_date'], + 'end_date' => $season['end_date'], + 'modifier' => (float) $season['modifier'], + 'priority' => (int) $season['priority'], + ); + } + + // Sort by priority (highest first). + usort( + $items, + function ( $a, $b ) { + return $b['priority'] - $a['priority']; + } + ); + + $response = $this->formatter->success( $items ); + + return $this->add_rate_limit_headers( $response, $request ); + } +} diff --git a/src/Api/Controllers/RoomsController.php b/src/Api/Controllers/RoomsController.php new file mode 100644 index 0000000..92680fd --- /dev/null +++ b/src/Api/Controllers/RoomsController.php @@ -0,0 +1,768 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'public_permission' ), + 'args' => $this->get_rooms_collection_params(), + ), + ) + ); + + // GET /rooms/{id} - Get single room. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'public_permission' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Room ID.', 'wp-bnb' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + ), + ), + ) + ); + + // GET /rooms/{id}/availability - Check room availability. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)/availability', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_availability' ), + 'permission_callback' => array( $this, 'public_permission' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Room ID.', 'wp-bnb' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + 'check_in' => array( + 'description' => __( 'Check-in date (Y-m-d).', 'wp-bnb' ), + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'check_out' => array( + 'description' => __( 'Check-out date (Y-m-d).', 'wp-bnb' ), + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ), + ) + ); + + // GET /rooms/{id}/calendar - Get room calendar. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)/calendar', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_calendar' ), + 'permission_callback' => array( $this, 'public_permission' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Room ID.', 'wp-bnb' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + 'year' => array( + 'description' => __( 'Year.', 'wp-bnb' ), + 'type' => 'integer', + 'default' => (int) gmdate( 'Y' ), + 'sanitize_callback' => 'absint', + ), + 'month' => array( + 'description' => __( 'Month (1-12).', 'wp-bnb' ), + 'type' => 'integer', + 'default' => (int) gmdate( 'n' ), + 'minimum' => 1, + 'maximum' => 12, + 'sanitize_callback' => 'absint', + ), + ), + ), + ) + ); + + // POST /availability/search - Search available rooms. + register_rest_route( + $this->namespace, + '/availability/search', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'search_availability' ), + 'permission_callback' => array( $this, 'public_permission' ), + 'args' => array( + 'check_in' => array( + 'description' => __( 'Check-in date (Y-m-d).', 'wp-bnb' ), + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'check_out' => array( + 'description' => __( 'Check-out date (Y-m-d).', 'wp-bnb' ), + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'guests' => array( + 'description' => __( 'Number of guests.', 'wp-bnb' ), + 'type' => 'integer', + 'minimum' => 1, + 'sanitize_callback' => 'absint', + ), + 'building_id' => array( + 'description' => __( 'Building ID.', 'wp-bnb' ), + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ), + 'room_type' => array( + 'description' => __( 'Room type term ID or slug.', 'wp-bnb' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'amenities' => array( + 'description' => __( 'Comma-separated amenity slugs.', 'wp-bnb' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'price_min' => array( + 'description' => __( 'Minimum price per night.', 'wp-bnb' ), + 'type' => 'number', + ), + 'price_max' => array( + 'description' => __( 'Maximum price per night.', 'wp-bnb' ), + 'type' => 'number', + ), + ), + ), + ) + ); + } + + /** + * Get collection of rooms. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function get_items( $request ) { + // Check rate limit. + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $pagination = $this->get_pagination_params( $request ); + $sorting = $this->get_sorting_params( $request, array( 'title', 'date', 'capacity', 'price' ), 'title' ); + + $args = array( + 'post_type' => Room::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => $pagination['per_page'], + 'offset' => $pagination['offset'], + ); + + // Handle special orderby values. + switch ( $sorting['orderby'] ) { + case 'capacity': + $args['meta_key'] = '_bnb_room_capacity'; + $args['orderby'] = 'meta_value_num'; + break; + case 'price': + $args['meta_key'] = '_bnb_room_price_short_term'; + $args['orderby'] = 'meta_value_num'; + break; + default: + $args['orderby'] = $sorting['orderby']; + } + $args['order'] = $sorting['order']; + + // Search filter. + $search = $request->get_param( 'search' ); + if ( $search ) { + $args['s'] = $search; + } + + // Building filter. + $building = $request->get_param( 'building' ); + if ( $building ) { + $args['meta_query'][] = array( + 'key' => '_bnb_room_building_id', + 'value' => $building, + ); + } + + // Status filter. + $status = $request->get_param( 'status' ); + if ( $status ) { + $args['meta_query'][] = array( + 'key' => '_bnb_room_status', + 'value' => $status, + ); + } + + // Capacity filter. + $capacity_min = $request->get_param( 'capacity_min' ); + if ( $capacity_min ) { + $args['meta_query'][] = array( + 'key' => '_bnb_room_capacity', + 'value' => $capacity_min, + 'compare' => '>=', + 'type' => 'NUMERIC', + ); + } + + // Room type filter. + $room_type = $request->get_param( 'room_type' ); + if ( $room_type ) { + $args['tax_query'][] = array( + 'taxonomy' => RoomType::TAXONOMY, + 'field' => is_numeric( $room_type ) ? 'term_id' : 'slug', + 'terms' => $room_type, + ); + } + + // Amenities filter. + $amenities = $request->get_param( 'amenities' ); + if ( $amenities ) { + $amenity_slugs = array_map( 'trim', explode( ',', $amenities ) ); + $args['tax_query'][] = array( + 'taxonomy' => Amenity::TAXONOMY, + 'field' => 'slug', + 'terms' => $amenity_slugs, + 'operator' => 'AND', + ); + } + + if ( isset( $args['meta_query'] ) && count( $args['meta_query'] ) > 1 ) { + $args['meta_query']['relation'] = 'AND'; + } + if ( isset( $args['tax_query'] ) && count( $args['tax_query'] ) > 1 ) { + $args['tax_query']['relation'] = 'AND'; + } + + $query = new \WP_Query( $args ); + $items = array(); + + foreach ( $query->posts as $post ) { + $items[] = $this->prepare_room_response( $post ); + } + + $response = $this->formatter->collection( + $items, + $query->found_posts, + $pagination['page'], + $pagination['per_page'] + ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Get single room. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function get_item( $request ) { + // Check rate limit. + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $id = $request->get_param( 'id' ); + $post = get_post( $id ); + + if ( ! $post || Room::POST_TYPE !== $post->post_type || 'publish' !== $post->post_status ) { + return $this->formatter->not_found( __( 'Room', 'wp-bnb' ) ); + } + + $data = $this->prepare_room_response( $post, true ); + $response = $this->formatter->success( $data ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Get room availability. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function get_availability( $request ) { + // Check rate limit. + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $room_id = $request->get_param( 'id' ); + $check_in = $request->get_param( 'check_in' ); + $check_out = $request->get_param( 'check_out' ); + + // Validate room exists. + $room = get_post( $room_id ); + if ( ! $room || Room::POST_TYPE !== $room->post_type || 'publish' !== $room->post_status ) { + return $this->formatter->not_found( __( 'Room', 'wp-bnb' ) ); + } + + // Validate dates. + if ( ! $this->validate_date( $check_in ) ) { + return $this->formatter->validation_error( 'check_in', __( 'Invalid check-in date format. Use Y-m-d.', 'wp-bnb' ) ); + } + if ( ! $this->validate_date( $check_out ) ) { + return $this->formatter->validation_error( 'check_out', __( 'Invalid check-out date format. Use Y-m-d.', 'wp-bnb' ) ); + } + if ( $check_in >= $check_out ) { + return $this->formatter->validation_error( 'check_out', __( 'Check-out must be after check-in.', 'wp-bnb' ) ); + } + + // Check availability. + $is_available = Availability::is_available( $room_id, $check_in, $check_out ); + + // Calculate nights. + $check_in_date = new \DateTimeImmutable( $check_in ); + $check_out_date = new \DateTimeImmutable( $check_out ); + $nights = (int) $check_in_date->diff( $check_out_date )->days; + + $data = array( + 'available' => $is_available, + 'room_id' => $room_id, + 'check_in' => $check_in, + 'check_out' => $check_out, + 'nights' => $nights, + ); + + if ( $is_available ) { + // Calculate pricing. + $price = Calculator::calculate( $room_id, $check_in, $check_out ); + $data['pricing'] = array( + 'tier' => $price['breakdown']['tier']->value ?? 'short_term', + 'base_rate' => $price['breakdown']['base_price_per_night'] ?? 0, + 'total' => $price['price'] ?? 0, + 'formatted' => $price['price_formatted'] ?? '', + 'currency' => get_option( 'wp_bnb_currency', 'CHF' ), + 'breakdown' => $price['breakdown'] ?? array(), + ); + } else { + // Get conflicts. + $conflicts = $this->get_conflicts( $room_id, $check_in, $check_out ); + $data['conflicts'] = $conflicts; + } + + $response = $this->formatter->success( $data ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Get room calendar. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function get_calendar( $request ) { + // Check rate limit. + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $room_id = $request->get_param( 'id' ); + $year = $request->get_param( 'year' ); + $month = $request->get_param( 'month' ); + + // Validate room exists. + $room = get_post( $room_id ); + if ( ! $room || Room::POST_TYPE !== $room->post_type || 'publish' !== $room->post_status ) { + return $this->formatter->not_found( __( 'Room', 'wp-bnb' ) ); + } + + // Validate month. + if ( $month < 1 || $month > 12 ) { + return $this->formatter->validation_error( 'month', __( 'Month must be between 1 and 12.', 'wp-bnb' ) ); + } + + $data = Availability::get_calendar_data( $room_id, $year, $month ); + $response = $this->formatter->success( $data ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Search available rooms. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function search_availability( $request ) { + // Check rate limit. + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $check_in = $request->get_param( 'check_in' ); + $check_out = $request->get_param( 'check_out' ); + + // Validate dates. + if ( ! $this->validate_date( $check_in ) ) { + return $this->formatter->validation_error( 'check_in', __( 'Invalid check-in date format. Use Y-m-d.', 'wp-bnb' ) ); + } + if ( ! $this->validate_date( $check_out ) ) { + return $this->formatter->validation_error( 'check_out', __( 'Invalid check-out date format. Use Y-m-d.', 'wp-bnb' ) ); + } + if ( $check_in >= $check_out ) { + return $this->formatter->validation_error( 'check_out', __( 'Check-out must be after check-in.', 'wp-bnb' ) ); + } + + // Build search args. + $search_args = array( + 'check_in' => $check_in, + 'check_out' => $check_out, + ); + + $guests = $request->get_param( 'guests' ); + if ( $guests ) { + $search_args['guests'] = $guests; + } + + $building_id = $request->get_param( 'building_id' ); + if ( $building_id ) { + $search_args['building_id'] = $building_id; + } + + $room_type = $request->get_param( 'room_type' ); + if ( $room_type ) { + $search_args['room_type'] = $room_type; + } + + $amenities = $request->get_param( 'amenities' ); + if ( $amenities ) { + $search_args['amenities'] = array_map( 'trim', explode( ',', $amenities ) ); + } + + $price_min = $request->get_param( 'price_min' ); + if ( $price_min ) { + $search_args['price_min'] = $price_min; + } + + $price_max = $request->get_param( 'price_max' ); + if ( $price_max ) { + $search_args['price_max'] = $price_max; + } + + // Use existing Search class. + $results = Search::search( $search_args ); + + // Format response. + $items = array(); + foreach ( $results['rooms'] as $room_data ) { + $items[] = array( + 'room' => $room_data, + 'availability' => array( + 'available' => true, + 'nights' => $room_data['nights'] ?? 0, + 'total_price' => $room_data['stay_price'] ?? 0, + 'formatted_price' => $room_data['stay_price_formatted'] ?? '', + ), + ); + } + + $data = array( + 'results' => $items, + 'total' => $results['count'], + 'filters_applied' => $search_args, + ); + + $response = $this->formatter->success( $data ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Get conflicting bookings for a date range. + * + * @param int $room_id Room ID. + * @param string $check_in Check-in date. + * @param string $check_out Check-out date. + * @return array Conflicting bookings. + */ + private function get_conflicts( int $room_id, string $check_in, string $check_out ): array { + $bookings = get_posts( + array( + 'post_type' => Booking::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => '_bnb_booking_room_id', + 'value' => $room_id, + ), + array( + 'key' => '_bnb_booking_status', + 'value' => 'cancelled', + 'compare' => '!=', + ), + array( + 'key' => '_bnb_booking_check_in', + 'value' => $check_out, + 'compare' => '<', + 'type' => 'DATE', + ), + array( + 'key' => '_bnb_booking_check_out', + 'value' => $check_in, + 'compare' => '>', + 'type' => 'DATE', + ), + ), + ) + ); + + $conflicts = array(); + foreach ( $bookings as $booking ) { + $conflicts[] = array( + 'booking_id' => $booking->ID, + 'reference' => $booking->post_title, + 'check_in' => get_post_meta( $booking->ID, '_bnb_booking_check_in', true ), + 'check_out' => get_post_meta( $booking->ID, '_bnb_booking_check_out', true ), + ); + } + + return $conflicts; + } + + /** + * Prepare room data for response. + * + * @param \WP_Post $post Room post object. + * @param bool $full Include full details. + * @return array Room data. + */ + private function prepare_room_response( \WP_Post $post, bool $full = false ): array { + $data = $this->format_post_base( $post ); + + $data['permalink'] = get_permalink( $post->ID ); + $data['featured_image'] = $this->format_featured_image( $post->ID ); + + // Gallery. + $gallery_ids = get_post_meta( $post->ID, '_bnb_room_gallery', true ); + $gallery = array(); + if ( $gallery_ids ) { + foreach ( explode( ',', $gallery_ids ) as $image_id ) { + $image = $this->format_image( (int) $image_id ); + if ( $image ) { + $gallery[] = $image; + } + } + } + $data['gallery'] = $gallery; + + // Building reference. + $building_id = get_post_meta( $post->ID, '_bnb_room_building_id', true ); + $building = $building_id ? get_post( $building_id ) : null; + $data['building'] = $building ? array( + 'id' => $building->ID, + 'title' => get_the_title( $building ), + 'slug' => $building->post_name, + 'permalink' => get_permalink( $building->ID ), + 'city' => get_post_meta( $building->ID, '_bnb_building_city', true ), + ) : null; + + // Room details. + $data['room_number'] = get_post_meta( $post->ID, '_bnb_room_room_number', true ); + $data['floor'] = (int) get_post_meta( $post->ID, '_bnb_room_floor', true ); + $data['size_sqm'] = (float) get_post_meta( $post->ID, '_bnb_room_size', true ); + + // Capacity. + $data['capacity'] = array( + 'max_guests' => (int) get_post_meta( $post->ID, '_bnb_room_capacity', true ), + 'adults' => (int) get_post_meta( $post->ID, '_bnb_room_max_adults', true ), + 'children' => (int) get_post_meta( $post->ID, '_bnb_room_max_children', true ), + ); + + $data['beds'] = get_post_meta( $post->ID, '_bnb_room_beds', true ); + $data['bathrooms'] = (float) get_post_meta( $post->ID, '_bnb_room_bathrooms', true ); + $data['status'] = get_post_meta( $post->ID, '_bnb_room_status', true ) ?: 'available'; + + // Room types. + $room_types = wp_get_post_terms( $post->ID, RoomType::TAXONOMY ); + $data['room_types'] = array_map( + function ( $term ) { + return array( + 'id' => $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + ); + }, + is_array( $room_types ) ? $room_types : array() + ); + + // Amenities. + $amenities = wp_get_post_terms( $post->ID, Amenity::TAXONOMY ); + $data['amenities'] = array_map( + function ( $term ) { + return array( + 'id' => $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + 'icon' => get_term_meta( $term->term_id, '_bnb_amenity_icon', true ), + ); + }, + is_array( $amenities ) ? $amenities : array() + ); + + // Pricing. + $pricing = Calculator::getRoomPricing( $post->ID ); + $currency = get_option( 'wp_bnb_currency', 'CHF' ); + + $data['pricing'] = array( + 'currency' => $currency, + ); + + foreach ( PricingTier::cases() as $tier ) { + $price = $pricing[ $tier->value ]['price'] ?? null; + $data['pricing'][ $tier->value ] = array( + 'price' => $price, + 'formatted' => $price ? Calculator::formatPrice( $price ) : null, + 'unit' => $tier->unit(), + ); + } + + $weekend_surcharge = $pricing['weekend_surcharge']['price'] ?? null; + $data['pricing']['weekend_surcharge'] = array( + 'price' => $weekend_surcharge, + 'formatted' => $weekend_surcharge ? Calculator::formatPrice( $weekend_surcharge ) : null, + ); + + // Add HATEOAS links. + $data['_links'] = array( + 'self' => array( + array( 'href' => rest_url( $this->namespace . '/rooms/' . $post->ID ) ), + ), + 'building' => $building ? array( + array( 'href' => rest_url( $this->namespace . '/buildings/' . $building->ID ) ), + ) : array(), + 'availability' => array( + array( 'href' => rest_url( $this->namespace . '/rooms/' . $post->ID . '/availability' ) ), + ), + 'calendar' => array( + array( 'href' => rest_url( $this->namespace . '/rooms/' . $post->ID . '/calendar' ) ), + ), + ); + + return $data; + } + + /** + * Get rooms collection parameters. + * + * @return array Collection parameters. + */ + private function get_rooms_collection_params(): array { + $params = $this->get_collection_params(); + + $params['building'] = array( + 'description' => __( 'Filter by building ID.', 'wp-bnb' ), + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ); + + $params['room_type'] = array( + 'description' => __( 'Filter by room type (term ID or slug).', 'wp-bnb' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ); + + $params['amenities'] = array( + 'description' => __( 'Filter by amenities (comma-separated slugs).', 'wp-bnb' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ); + + $params['capacity_min'] = array( + 'description' => __( 'Minimum guest capacity.', 'wp-bnb' ), + 'type' => 'integer', + 'minimum' => 1, + 'sanitize_callback' => 'absint', + ); + + $params['status'] = array( + 'description' => __( 'Filter by room status.', 'wp-bnb' ), + 'type' => 'string', + 'enum' => array( 'available', 'occupied', 'maintenance', 'blocked' ), + 'sanitize_callback' => 'sanitize_text_field', + ); + + $params['orderby']['enum'] = array( 'title', 'date', 'capacity', 'price' ); + + return $params; + } +} diff --git a/src/Api/Controllers/ServicesController.php b/src/Api/Controllers/ServicesController.php new file mode 100644 index 0000000..c42d693 --- /dev/null +++ b/src/Api/Controllers/ServicesController.php @@ -0,0 +1,375 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'public_permission' ), + 'args' => $this->get_services_collection_params(), + ), + ) + ); + + // GET /services/{id} - Get single service (public). + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'public_permission' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Service ID.', 'wp-bnb' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + ), + ), + ) + ); + + // POST /services/{id}/calculate - Calculate service price. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)/calculate', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'calculate_price' ), + 'permission_callback' => array( $this, 'public_permission' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Service ID.', 'wp-bnb' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + 'quantity' => array( + 'description' => __( 'Quantity.', 'wp-bnb' ), + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + 'sanitize_callback' => 'absint', + ), + 'nights' => array( + 'description' => __( 'Number of nights (for per-night services).', 'wp-bnb' ), + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + 'sanitize_callback' => 'absint', + ), + ), + ), + ) + ); + } + + /** + * Get collection of services. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function get_items( $request ) { + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $args = array( + 'post_type' => Service::POST_TYPE, + 'post_status' => 'publish', + 'posts_per_page' => 100, // Services typically don't need pagination. + 'orderby' => 'meta_value_num', + 'meta_key' => '_bnb_service_sort_order', + 'order' => 'ASC', + ); + + $meta_query = array(); + + // Status filter (default: active only). + $status = $request->get_param( 'status' ) ?: 'active'; + if ( 'all' !== $status ) { + $meta_query[] = array( + 'key' => '_bnb_service_status', + 'value' => $status, + ); + } + + // Pricing type filter. + $pricing_type = $request->get_param( 'pricing_type' ); + if ( $pricing_type ) { + $meta_query[] = array( + 'key' => '_bnb_service_pricing_type', + 'value' => $pricing_type, + ); + } + + if ( ! empty( $meta_query ) ) { + $meta_query['relation'] = 'AND'; + $args['meta_query'] = $meta_query; + } + + // Category filter. + $category = $request->get_param( 'category' ); + if ( $category ) { + $args['tax_query'] = array( + array( + 'taxonomy' => 'bnb_service_category', + 'field' => is_numeric( $category ) ? 'term_id' : 'slug', + 'terms' => $category, + ), + ); + } + + $services = get_posts( $args ); + $items = array(); + + foreach ( $services as $service ) { + $items[] = $this->prepare_service_response( $service ); + } + + $response = $this->formatter->success( $items ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Get single service. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function get_item( $request ) { + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $id = $request->get_param( 'id' ); + $post = get_post( $id ); + + if ( ! $post || Service::POST_TYPE !== $post->post_type || 'publish' !== $post->post_status ) { + return $this->formatter->not_found( __( 'Service', 'wp-bnb' ) ); + } + + $data = $this->prepare_service_response( $post, true ); + $response = $this->formatter->success( $data ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Calculate service price. + * + * @param WP_REST_Request $request Current request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function calculate_price( $request ) { + $rate_limit_error = $this->check_rate_limit( $request ); + if ( $rate_limit_error ) { + return $rate_limit_error; + } + + $service_id = $request->get_param( 'id' ); + $quantity = $request->get_param( 'quantity' ); + $nights = $request->get_param( 'nights' ); + + // Validate service. + $service = get_post( $service_id ); + if ( ! $service || Service::POST_TYPE !== $service->post_type || 'publish' !== $service->post_status ) { + return $this->formatter->not_found( __( 'Service', 'wp-bnb' ) ); + } + + // Check if service is active. + $status = get_post_meta( $service_id, '_bnb_service_status', true ); + if ( 'active' !== $status ) { + return $this->formatter->validation_error( 'id', __( 'Service is not available.', 'wp-bnb' ) ); + } + + // Check max quantity. + $max_quantity = (int) get_post_meta( $service_id, '_bnb_service_max_quantity', true ) ?: 1; + if ( $quantity > $max_quantity ) { + return $this->formatter->validation_error( + 'quantity', + sprintf( + /* translators: %d: maximum quantity */ + __( 'Maximum quantity is %d.', 'wp-bnb' ), + $max_quantity + ) + ); + } + + // Calculate price. + $total = Service::calculate_service_price( $service_id, $quantity, $nights ); + $pricing_type = get_post_meta( $service_id, '_bnb_service_pricing_type', true ); + $unit_price = (float) get_post_meta( $service_id, '_bnb_service_price', true ); + $currency = get_option( 'wp_bnb_currency', 'CHF' ); + + // Build calculation string. + $calculation = ''; + switch ( $pricing_type ) { + case 'included': + $calculation = __( 'Included', 'wp-bnb' ); + break; + case 'per_booking': + $calculation = sprintf( + '%s x %d', + Calculator::formatPrice( $unit_price ), + $quantity + ); + break; + case 'per_night': + $calculation = sprintf( + '%s x %d x %d %s', + Calculator::formatPrice( $unit_price ), + $quantity, + $nights, + _n( 'night', 'nights', $nights, 'wp-bnb' ) + ); + break; + } + + $data = array( + 'service_id' => $service_id, + 'quantity' => $quantity, + 'nights' => $nights, + 'unit_price' => $unit_price, + 'total' => $total, + 'formatted' => Calculator::formatPrice( $total ), + 'currency' => $currency, + 'calculation' => $calculation, + ); + + $response = $this->formatter->success( $data ); + + return $this->add_rate_limit_headers( $response, $request ); + } + + /** + * Prepare service data for response. + * + * @param \WP_Post $post Service post object. + * @param bool $full Include full details. + * @return array Service data. + */ + private function prepare_service_response( \WP_Post $post, bool $full = false ): array { + $pricing_type = get_post_meta( $post->ID, '_bnb_service_pricing_type', true ); + $price = (float) get_post_meta( $post->ID, '_bnb_service_price', true ); + $status = get_post_meta( $post->ID, '_bnb_service_status', true ) ?: 'active'; + $max_quantity = (int) get_post_meta( $post->ID, '_bnb_service_max_quantity', true ) ?: 1; + $currency = get_option( 'wp_bnb_currency', 'CHF' ); + + $data = array( + 'id' => $post->ID, + 'title' => get_the_title( $post ), + 'slug' => $post->post_name, + 'description' => get_the_excerpt( $post ), + 'pricing' => array( + 'type' => $pricing_type, + 'price' => $price, + 'formatted' => Service::format_service_price( Service::get_service_data( $post->ID ) ), + 'currency' => $currency, + ), + 'max_quantity' => $max_quantity, + 'status' => $status, + ); + + // Category. + $categories = wp_get_post_terms( $post->ID, 'bnb_service_category' ); + if ( ! empty( $categories ) && ! is_wp_error( $categories ) ) { + $category = $categories[0]; + $data['category'] = array( + 'id' => $category->term_id, + 'name' => $category->name, + 'slug' => $category->slug, + 'icon' => get_term_meta( $category->term_id, '_bnb_service_category_icon', true ), + ); + } + + if ( $full ) { + $data['content'] = apply_filters( 'the_content', $post->post_content ); + $data['sort_order'] = (int) get_post_meta( $post->ID, '_bnb_service_sort_order', true ); + } + + $data['_links'] = array( + 'self' => array( + array( 'href' => rest_url( $this->namespace . '/services/' . $post->ID ) ), + ), + ); + + return $data; + } + + /** + * Get services collection parameters. + * + * @return array Collection parameters. + */ + private function get_services_collection_params(): array { + return array( + 'status' => array( + 'description' => __( 'Filter by status (default: active).', 'wp-bnb' ), + 'type' => 'string', + 'enum' => array( 'active', 'inactive', 'all' ), + 'default' => 'active', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'pricing_type' => array( + 'description' => __( 'Filter by pricing type.', 'wp-bnb' ), + 'type' => 'string', + 'enum' => array( 'included', 'per_booking', 'per_night' ), + 'sanitize_callback' => 'sanitize_text_field', + ), + 'category' => array( + 'description' => __( 'Filter by category (term ID or slug).', 'wp-bnb' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + ); + } +} diff --git a/src/Api/RateLimiter.php b/src/Api/RateLimiter.php new file mode 100644 index 0000000..383750d --- /dev/null +++ b/src/Api/RateLimiter.php @@ -0,0 +1,194 @@ + + */ + private array $limits = array( + 'public' => 60, // Public read endpoints. + 'availability' => 30, // Availability checks. + 'booking' => 10, // Booking creation. + 'admin' => 120, // Admin endpoints. + ); + + /** + * Time window in seconds. + * + * @var int + */ + private int $window = 60; + + /** + * Check if request is within rate limit. + * + * @param string $identifier Client identifier (user ID or IP). + * @param string $endpoint Request endpoint. + * @return bool True if within limit, false if exceeded. + */ + public function check( string $identifier, string $endpoint ): bool { + $type = $this->get_endpoint_type( $endpoint ); + $limit = $this->limits[ $type ] ?? $this->limits['public']; + $key = $this->get_transient_key( $identifier, $type ); + $data = get_transient( $key ); + + if ( false === $data ) { + // First request in window. + set_transient( + $key, + array( + 'count' => 1, + 'start' => time(), + ), + $this->window + ); + return true; + } + + // Check if window expired. + if ( time() - $data['start'] >= $this->window ) { + set_transient( + $key, + array( + 'count' => 1, + 'start' => time(), + ), + $this->window + ); + return true; + } + + // Check if limit exceeded. + if ( $data['count'] >= $limit ) { + return false; + } + + // Increment counter. + ++$data['count']; + $remaining_window = $this->window - ( time() - $data['start'] ); + set_transient( $key, $data, $remaining_window ); + + return true; + } + + /** + * Get seconds until rate limit resets. + * + * @param string $identifier Client identifier. + * @param string $endpoint Request endpoint. + * @return int Seconds until reset. + */ + public function get_retry_after( string $identifier, string $endpoint ): int { + $type = $this->get_endpoint_type( $endpoint ); + $key = $this->get_transient_key( $identifier, $type ); + $data = get_transient( $key ); + + if ( false === $data ) { + return 0; + } + + return max( 0, $this->window - ( time() - $data['start'] ) ); + } + + /** + * Get current rate limit info for headers. + * + * @param string $identifier Client identifier. + * @param string $endpoint Request endpoint. + * @return array{limit: int, remaining: int, reset: int} + */ + public function get_rate_limit_info( string $identifier, string $endpoint ): array { + $type = $this->get_endpoint_type( $endpoint ); + $limit = $this->limits[ $type ] ?? $this->limits['public']; + $key = $this->get_transient_key( $identifier, $type ); + $data = get_transient( $key ); + + if ( false === $data ) { + return array( + 'limit' => $limit, + 'remaining' => $limit, + 'reset' => time() + $this->window, + ); + } + + $remaining = max( 0, $limit - $data['count'] ); + $reset = $data['start'] + $this->window; + + return array( + 'limit' => $limit, + 'remaining' => $remaining, + 'reset' => $reset, + ); + } + + /** + * Determine endpoint type from route. + * + * @param string $endpoint Request endpoint. + * @return string Endpoint type. + */ + private function get_endpoint_type( string $endpoint ): string { + if ( str_contains( $endpoint, '/availability' ) || str_contains( $endpoint, '/calendar' ) ) { + return 'availability'; + } + if ( str_contains( $endpoint, '/bookings' ) ) { + return 'booking'; + } + if ( str_contains( $endpoint, '/guests' ) ) { + return 'admin'; + } + return 'public'; + } + + /** + * Get transient key for rate limit data. + * + * @param string $identifier Client identifier. + * @param string $type Endpoint type. + * @return string Transient key. + */ + private function get_transient_key( string $identifier, string $type ): string { + return self::TRANSIENT_PREFIX . md5( $identifier . '_' . $type ); + } + + /** + * Set custom rate limits. + * + * @param array $limits Rate limits by type. + * @return void + */ + public function set_limits( array $limits ): void { + $this->limits = array_merge( $this->limits, $limits ); + } + + /** + * Set custom time window. + * + * @param int $window Window in seconds. + * @return void + */ + public function set_window( int $window ): void { + $this->window = $window; + } +} diff --git a/src/Api/ResponseFormatter.php b/src/Api/ResponseFormatter.php new file mode 100644 index 0000000..b28269e --- /dev/null +++ b/src/Api/ResponseFormatter.php @@ -0,0 +1,171 @@ +header( 'X-WP-Total', (string) $total ); + $response->header( 'X-WP-TotalPages', (string) $max_pages ); + + return $response; + } + + /** + * Format created response (201). + * + * @param mixed $data Response data. + * @param string $location Location header URL. + * @return WP_REST_Response + */ + public function created( mixed $data, string $location = '' ): WP_REST_Response { + $response = new WP_REST_Response( $data, 201 ); + if ( $location ) { + $response->header( 'Location', $location ); + } + return $response; + } + + /** + * Format no content response (204). + * + * @return WP_REST_Response + */ + public function no_content(): WP_REST_Response { + return new WP_REST_Response( null, 204 ); + } + + /** + * Create validation error. + * + * @param string $param Parameter name. + * @param string $message Error message. + * @return WP_Error + */ + public function validation_error( string $param, string $message ): WP_Error { + return new WP_Error( + 'rest_invalid_param', + $message, + array( + 'status' => 400, + 'param' => $param, + ) + ); + } + + /** + * Create not found error. + * + * @param string $resource Resource name. + * @return WP_Error + */ + public function not_found( string $resource = 'Resource' ): WP_Error { + return new WP_Error( + 'rest_not_found', + /* translators: %s: Resource name */ + sprintf( __( '%s not found.', 'wp-bnb' ), $resource ), + array( 'status' => 404 ) + ); + } + + /** + * Create forbidden error. + * + * @param string $message Error message. + * @return WP_Error + */ + public function forbidden( string $message = '' ): WP_Error { + return new WP_Error( + 'rest_forbidden', + $message ?: __( 'You do not have permission to access this resource.', 'wp-bnb' ), + array( 'status' => 403 ) + ); + } + + /** + * Create rate limit error. + * + * @param int $retry_after Seconds until rate limit resets. + * @return WP_Error + */ + public function rate_limit_error( int $retry_after = 60 ): WP_Error { + return new WP_Error( + 'rest_rate_limit_exceeded', + __( 'Rate limit exceeded. Please try again later.', 'wp-bnb' ), + array( + 'status' => 429, + 'retry_after' => $retry_after, + ) + ); + } + + /** + * Create conflict error (e.g., booking conflict). + * + * @param string $message Error message. + * @param array $conflicts Conflicting resources. + * @return WP_Error + */ + public function conflict( string $message, array $conflicts = array() ): WP_Error { + return new WP_Error( + 'rest_conflict', + $message, + array( + 'status' => 409, + 'conflicts' => $conflicts, + ) + ); + } + + /** + * Create internal server error. + * + * @param string $message Error message. + * @return WP_Error + */ + public function server_error( string $message = '' ): WP_Error { + return new WP_Error( + 'rest_server_error', + $message ?: __( 'An internal server error occurred.', 'wp-bnb' ), + array( 'status' => 500 ) + ); + } +} diff --git a/src/Api/RestApi.php b/src/Api/RestApi.php new file mode 100644 index 0000000..8759c90 --- /dev/null +++ b/src/Api/RestApi.php @@ -0,0 +1,113 @@ +controllers = array( + new BuildingsController(), + new RoomsController(), + new BookingsController(), + new GuestsController(), + new ServicesController(), + new PricingController(), + ); + + foreach ( $this->controllers as $controller ) { + $controller->register_routes(); + } + + // Register API info endpoint. + register_rest_route( + self::NAMESPACE, + '/info', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'get_api_info' ), + 'permission_callback' => '__return_true', + ) + ); + } + + /** + * Get API information. + * + * @return \WP_REST_Response API info response. + */ + public function get_api_info(): \WP_REST_Response { + return new \WP_REST_Response( + array( + 'name' => 'WP BnB REST API', + 'version' => self::VERSION, + 'namespace' => self::NAMESPACE, + 'description' => __( 'REST API for WP BnB booking management.', 'wp-bnb' ), + 'endpoints' => array( + 'buildings' => rest_url( self::NAMESPACE . '/buildings' ), + 'rooms' => rest_url( self::NAMESPACE . '/rooms' ), + 'bookings' => rest_url( self::NAMESPACE . '/bookings' ), + 'guests' => rest_url( self::NAMESPACE . '/guests' ), + 'services' => rest_url( self::NAMESPACE . '/services' ), + 'pricing' => rest_url( self::NAMESPACE . '/pricing' ), + 'availability' => rest_url( self::NAMESPACE . '/availability' ), + ), + ), + 200 + ); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 0fdb401..e81b8a6 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -18,6 +18,7 @@ use Magdev\WpBnb\Booking\Availability; use Magdev\WpBnb\Booking\EmailNotifier; use Magdev\WpBnb\Frontend\Search; use Magdev\WpBnb\Frontend\Shortcodes; +use Magdev\WpBnb\Api\RestApi; use Magdev\WpBnb\Integration\CF7; use Magdev\WpBnb\Integration\Prometheus; use Magdev\WpBnb\Frontend\Widgets\AvailabilityCalendar; @@ -146,6 +147,9 @@ final class Plugin { // Initialize Prometheus metrics integration. Prometheus::init(); + // Initialize REST API. + $this->init_rest_api(); + // Initialize admin components. if ( is_admin() ) { $this->init_admin(); @@ -170,6 +174,16 @@ final class Plugin { $updater->init(); } + /** + * Initialize the REST API. + * + * @return void + */ + private function init_rest_api(): void { + $api = new RestApi(); + $api->init(); + } + /** * Initialize admin components. * @@ -618,6 +632,10 @@ final class Plugin { class="nav-tab "> + + +
@@ -635,6 +653,9 @@ final class Plugin { case 'metrics': $this->render_metrics_settings(); break; + case 'api': + $this->render_api_settings(); + break; default: $this->render_general_settings(); break; @@ -1433,6 +1454,157 @@ final class Plugin { +
+ + +

+ + + + + + + + + + +
+ +

+ +

+
+ +

+ +

+
+ +

+ + + + + + + + + + + + + + +
+ +

+ +

+
+ +
+ +

+ +

+
+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + +
GET/buildings
GET/buildings/{id}
GET/buildings/{id}/rooms
GET/rooms
GET/rooms/{id}
GET/rooms/{id}/availability
GET/rooms/{id}/calendar
POST/availability/search
GET/services
POST/pricing/calculate
POST/bookings
+ +

+ + + + + + + + + + + + + + + + + + + + +
GET/bookings
GET/bookings/{id}
PATCH/bookings/{id}
DELETE/bookings/{id}
POST/bookings/{id}/confirm
POST/bookings/{id}/check-in
POST/bookings/{id}/check-out
GET/guests
GET/guests/{id}
GET/guests/search
+ +

+

+
    +
  • Your Profile. Use Basic Auth with username and app password.', 'wp-bnb' ); ?>
  • +
  • +
+ +

+ + + + + + + + + + + + + + +
60/min
30/min
10/min
120/min
+ +

+ +

+
+ save_metrics_settings(); break; + case 'api': + $this->save_api_settings(); + break; default: $this->save_general_settings(); break; @@ -1649,6 +1824,22 @@ final class Plugin { settings_errors( 'wp_bnb_settings' ); } + /** + * Save API settings. + * + * @return void + */ + private function save_api_settings(): void { + $api_enabled = isset( $_POST['wp_bnb_api_enabled'] ) ? 'yes' : 'no'; + $rate_limiting = isset( $_POST['wp_bnb_api_rate_limiting'] ) ? 'yes' : 'no'; + + update_option( 'wp_bnb_api_enabled', $api_enabled ); + update_option( 'wp_bnb_api_rate_limiting', $rate_limiting ); + + add_settings_error( 'wp_bnb_settings', 'settings_saved', __( 'API settings saved.', 'wp-bnb' ), 'success' ); + settings_errors( 'wp_bnb_settings' ); + } + /** * AJAX handler for checking room availability. * diff --git a/wp-bnb.php b/wp-bnb.php index b1b6caf..7985b7e 100644 --- a/wp-bnb.php +++ b/wp-bnb.php @@ -3,7 +3,7 @@ * Plugin Name: WP BnB Management * Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-bnb * Description: A comprehensive Bed & Breakfast management system for WordPress. Manage buildings, rooms, bookings, and guests. - * Version: 0.9.0 + * Version: 0.10.0 * Requires at least: 6.0 * Requires PHP: 8.3 * Author: Marco Graetsch @@ -24,7 +24,7 @@ if ( ! defined( 'ABSPATH' ) ) { } // Plugin version constant - MUST match Version in header above. -define( 'WP_BNB_VERSION', '0.9.0' ); +define( 'WP_BNB_VERSION', '0.10.0' ); // Plugin path constants. define( 'WP_BNB_PATH', plugin_dir_path( __FILE__ ) );