5 Commits

Author SHA1 Message Date
3f5adfb04e Bump version to 0.10.1 for configurable rate limiting release
All checks were successful
Create Release Package / build-release (push) Successful in 1m4s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:52:00 +01:00
b701d127f8 Add configurable API rate limits with subtabs in settings (v0.10.0)
- Make rate limiting configurable via WordPress options
- Add subtabs to API settings: General, Rate Limits, Endpoints
- Add HTTP method badges for endpoint documentation
- Update CHANGELOG with rate limiting configuration details

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:50:41 +01:00
481495805b added MARKETING.md 2026-02-03 21:36:59 +01:00
81c97c31d7 Implement Phase 10: REST API Endpoints (v0.10.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m10s
- 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 <noreply@anthropic.com>
2026-02-03 21:24:40 +01:00
87aa89b1a6 Reorganize roadmap: add Phase 10 API Endpoints, renumber Security Audit
- API Endpoints promoted from Future Considerations to Phase 10 (v0.10.0)
- Security Audit moved from Phase 10 to Phase 11 (v0.11.0)
- Updated version milestones table accordingly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 20:56:39 +01:00
17 changed files with 4747 additions and 11 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ wp-plugins
wp-core
vendor/
releases/*
MARKETING.md

View File

@@ -5,6 +5,85 @@ 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.1] - 2026-02-03
### Added
- API Settings subtabs for better organization:
- General subtab: Enable/disable REST API, rate limiting toggle, API information
- Rate Limits subtab: Configurable time window and endpoint-specific limits
- Endpoints subtab: Full endpoint documentation with HTTP method badges
- Configurable rate limiting:
- Time window setting (10-300 seconds, default 60)
- Per-endpoint-type limits (public, availability, booking, admin)
- Settings stored in WordPress options with fallback defaults
### Changed
- RateLimiter class now loads limits from WordPress options
- README updated with configurable rate limiting documentation
## [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

23
PLAN.md
View File

@@ -194,10 +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: Security Audit (v0.10.0)
### Phase 10: API Endpoints (v0.10.0) - Complete
- [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 <http://localhost:9080/> for common vulnerabilities
## Future Considerations (v1.0.0+)
@@ -208,13 +219,6 @@ This document outlines the implementation plan for the WP BnB Management plugin.
- [ ] Order management
- [ ] Refund handling
### API Endpoints
- [ ] REST API for rooms
- [ ] REST API for availability
- [ ] REST API for bookings
- [ ] Authentication and rate limiting
### Multi-language Support
- [ ] Full translation support
@@ -317,5 +321,6 @@ The plugin will provide extensive hooks for customization:
| 0.7.0 | CF7 Integration | Complete |
| 0.8.0 | Dashboard | Complete |
| 0.9.0 | Prometheus Metrics | Complete |
| 0.10.0 | Security Audit | TBD |
| 0.10.0 | API Endpoints | TBD |
| 0.11.0 | Security Audit | TBD |
| 1.0.0 | Stable Release | TBD |

208
README.md
View File

@@ -444,6 +444,214 @@ 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. In the **General** subtab, enable "Enable REST API"
3. Optionally enable rate limiting for protection against abuse
4. Configure rate limits in the **Rate Limits** subtab
5. View all available endpoints in the **Endpoints** subtab
### 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). Configure limits in **Settings → API → Rate Limits**.
**Default Limits:**
| Type | Default | 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 |
**Configuration Options:**
- **Time Window**: 10-300 seconds (default: 60 seconds)
- **Per-endpoint limits**: Customize for each endpoint type
- **Rate limiting toggle**: Enable/disable without losing settings
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?

View File

@@ -491,6 +491,37 @@
height: 16px;
}
/* API Method Badges */
.wp-bnb-method {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
}
.wp-bnb-method.get {
background: #e7f5e7;
color: #1e7e1e;
}
.wp-bnb-method.post {
background: #e7f0f5;
color: #1e5f7e;
}
.wp-bnb-method.patch {
background: #f5f0e7;
color: #7e5f1e;
}
.wp-bnb-method.delete {
background: #f5e7e7;
color: #7e1e1e;
}
/* Form Tables */
.form-table th {
width: 200px;

View File

@@ -0,0 +1,382 @@
<?php
/**
* Abstract REST Controller
*
* Base class for all REST API controllers with common functionality.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\Api\RestApi;
use Magdev\WpBnb\Api\RateLimiter;
use Magdev\WpBnb\Api\ResponseFormatter;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
/**
* Abstract Controller class.
*/
abstract class AbstractController extends WP_REST_Controller {
/**
* API namespace.
*
* @var string
*/
protected $namespace = RestApi::NAMESPACE;
/**
* Rate limiter instance.
*
* @var RateLimiter
*/
protected RateLimiter $rate_limiter;
/**
* Response formatter instance.
*
* @var ResponseFormatter
*/
protected ResponseFormatter $formatter;
/**
* Constructor.
*/
public function __construct() {
$this->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',
),
);
}
}

View File

@@ -0,0 +1,930 @@
<?php
/**
* Bookings REST Controller
*
* Handles REST API endpoints for bookings.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Booking;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\PostTypes\Guest;
use Magdev\WpBnb\PostTypes\Service;
use Magdev\WpBnb\Booking\Availability;
use Magdev\WpBnb\Booking\EmailNotifier;
use Magdev\WpBnb\Pricing\Calculator;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Bookings Controller class.
*/
final class BookingsController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'bookings';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// GET /bookings - List bookings (admin).
register_rest_route(
$this->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<id>[\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<id>[\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<id>[\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<id>[\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<id>[\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<id>[\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',
),
);
}
}

View File

@@ -0,0 +1,323 @@
<?php
/**
* Buildings REST Controller
*
* Handles REST API endpoints for buildings.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Building;
use Magdev\WpBnb\PostTypes\Room;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Buildings Controller class.
*/
final class BuildingsController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'buildings';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// GET /buildings - List all buildings.
register_rest_route(
$this->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<id>[\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<id>[\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 ) ),
),
),
);
}
}

View File

@@ -0,0 +1,452 @@
<?php
/**
* Guests REST Controller
*
* Handles REST API endpoints for guests (admin only).
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Guest;
use Magdev\WpBnb\PostTypes\Booking;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Guests Controller class.
*/
final class GuestsController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'guests';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// GET /guests - List guests (admin).
register_rest_route(
$this->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<id>[\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<id>[\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;
}
}

View File

@@ -0,0 +1,278 @@
<?php
/**
* Pricing REST Controller
*
* Handles REST API endpoints for price calculations.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\PostTypes\Service;
use Magdev\WpBnb\Pricing\Calculator;
use Magdev\WpBnb\Pricing\Season;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Pricing Controller class.
*/
final class PricingController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'pricing';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// POST /pricing/calculate - Calculate full booking price.
register_rest_route(
$this->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 );
}
}

View File

@@ -0,0 +1,768 @@
<?php
/**
* Rooms REST Controller
*
* Handles REST API endpoints for rooms, availability, and calendar.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\PostTypes\Building;
use Magdev\WpBnb\PostTypes\Booking;
use Magdev\WpBnb\Booking\Availability;
use Magdev\WpBnb\Pricing\Calculator;
use Magdev\WpBnb\Pricing\PricingTier;
use Magdev\WpBnb\Taxonomies\RoomType;
use Magdev\WpBnb\Taxonomies\Amenity;
use Magdev\WpBnb\Frontend\Search;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Rooms Controller class.
*/
final class RoomsController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'rooms';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// GET /rooms - List all rooms.
register_rest_route(
$this->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<id>[\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<id>[\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<id>[\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;
}
}

View File

@@ -0,0 +1,375 @@
<?php
/**
* Services REST Controller
*
* Handles REST API endpoints for services.
*
* @package Magdev\WpBnb\Api\Controllers
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api\Controllers;
use Magdev\WpBnb\PostTypes\Service;
use Magdev\WpBnb\Taxonomies\ServiceCategory;
use Magdev\WpBnb\Pricing\Calculator;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
/**
* Services Controller class.
*/
final class ServicesController extends AbstractController {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'services';
/**
* Register routes.
*
* @return void
*/
public function register_routes(): void {
// GET /services - List active services (public).
register_rest_route(
$this->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<id>[\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<id>[\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',
),
);
}
}

232
src/Api/RateLimiter.php Normal file
View File

@@ -0,0 +1,232 @@
<?php
/**
* REST API Rate Limiter
*
* Provides transient-based rate limiting for API endpoints.
*
* @package Magdev\WpBnb\Api
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api;
/**
* Rate Limiter class.
*/
final class RateLimiter {
/**
* Transient prefix for rate limit data.
*/
private const TRANSIENT_PREFIX = 'wp_bnb_rate_';
/**
* Default rate limits per minute by endpoint type.
*
* @var array<string, int>
*/
private const DEFAULT_LIMITS = array(
'public' => 60, // Public read endpoints.
'availability' => 30, // Availability checks.
'booking' => 10, // Booking creation.
'admin' => 120, // Admin endpoints.
);
/**
* Rate limits per minute by endpoint type.
*
* @var array<string, int>
*/
private array $limits;
/**
* Time window in seconds.
*
* @var int
*/
private int $window;
/**
* Constructor.
*/
public function __construct() {
$this->load_limits_from_options();
}
/**
* Load rate limits from WordPress options.
*
* @return void
*/
private function load_limits_from_options(): void {
$this->limits = array(
'public' => (int) get_option( 'wp_bnb_rate_limit_public', self::DEFAULT_LIMITS['public'] ),
'availability' => (int) get_option( 'wp_bnb_rate_limit_availability', self::DEFAULT_LIMITS['availability'] ),
'booking' => (int) get_option( 'wp_bnb_rate_limit_booking', self::DEFAULT_LIMITS['booking'] ),
'admin' => (int) get_option( 'wp_bnb_rate_limit_admin', self::DEFAULT_LIMITS['admin'] ),
);
$this->window = (int) get_option( 'wp_bnb_rate_limit_window', 60 );
}
/**
* Get default rate limits.
*
* @return array<string, int>
*/
public static function get_default_limits(): array {
return self::DEFAULT_LIMITS;
}
/**
* 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<string, int> $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;
}
}

View File

@@ -0,0 +1,171 @@
<?php
/**
* REST API Response Formatter
*
* Provides standardized response formatting for all API endpoints.
*
* @package Magdev\WpBnb\Api
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api;
use WP_REST_Response;
use WP_Error;
/**
* Response Formatter class.
*/
final class ResponseFormatter {
/**
* Format successful response.
*
* @param mixed $data Response data.
* @param int $status HTTP status code.
* @return WP_REST_Response
*/
public function success( mixed $data, int $status = 200 ): WP_REST_Response {
return new WP_REST_Response( $data, $status );
}
/**
* Format collection response with pagination headers.
*
* @param array $items Collection items.
* @param int $total Total number of items.
* @param int $page Current page number.
* @param int $per_page Items per page.
* @return WP_REST_Response
*/
public function collection( array $items, int $total, int $page, int $per_page ): WP_REST_Response {
$response = new WP_REST_Response( $items, 200 );
$max_pages = (int) ceil( $total / $per_page );
$response->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 )
);
}
}

113
src/Api/RestApi.php Normal file
View File

@@ -0,0 +1,113 @@
<?php
/**
* REST API Main Class
*
* Registers all REST API controllers and routes.
*
* @package Magdev\WpBnb\Api
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Api;
use Magdev\WpBnb\Api\Controllers\BuildingsController;
use Magdev\WpBnb\Api\Controllers\RoomsController;
use Magdev\WpBnb\Api\Controllers\BookingsController;
use Magdev\WpBnb\Api\Controllers\GuestsController;
use Magdev\WpBnb\Api\Controllers\ServicesController;
use Magdev\WpBnb\Api\Controllers\PricingController;
/**
* REST API class.
*/
final class RestApi {
/**
* API namespace.
*/
public const NAMESPACE = 'wp-bnb/v1';
/**
* API version.
*/
public const VERSION = '1.0.0';
/**
* Controller instances.
*
* @var array
*/
private array $controllers = array();
/**
* Initialize the REST API.
*
* @return void
*/
public function init(): void {
// Check if API is enabled.
if ( 'yes' !== get_option( 'wp_bnb_api_enabled', 'yes' ) ) {
return;
}
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register all API routes.
*
* @return void
*/
public function register_routes(): void {
$this->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
);
}
}

View File

@@ -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 <?php echo 'metrics' === $active_tab ? 'nav-tab-active' : ''; ?>">
<?php esc_html_e( 'Metrics', 'wp-bnb' ); ?>
</a>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=wp-bnb-settings&tab=api' ) ); ?>"
class="nav-tab <?php echo 'api' === $active_tab ? 'nav-tab-active' : ''; ?>">
<?php esc_html_e( 'API', 'wp-bnb' ); ?>
</a>
</nav>
<div class="tab-content">
@@ -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,329 @@ final class Plugin {
<?php
}
/**
* Render API settings tab.
*
* @return void
*/
private function render_api_settings(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Subtab switching only.
$active_subtab = isset( $_GET['subtab'] ) ? sanitize_key( $_GET['subtab'] ) : 'general';
$api_enabled = get_option( 'wp_bnb_api_enabled', 'yes' );
$rate_limiting = get_option( 'wp_bnb_api_rate_limiting', 'yes' );
$api_base_url = rest_url( RestApi::NAMESPACE );
// Rate limit values.
$defaults = \Magdev\WpBnb\Api\RateLimiter::get_default_limits();
$limit_public = get_option( 'wp_bnb_rate_limit_public', $defaults['public'] );
$limit_avail = get_option( 'wp_bnb_rate_limit_availability', $defaults['availability'] );
$limit_booking = get_option( 'wp_bnb_rate_limit_booking', $defaults['booking'] );
$limit_admin = get_option( 'wp_bnb_rate_limit_admin', $defaults['admin'] );
$limit_window = get_option( 'wp_bnb_rate_limit_window', 60 );
$base_url = admin_url( 'admin.php?page=wp-bnb-settings&tab=api' );
?>
<!-- API Subtabs -->
<div class="wp-bnb-subtabs">
<a href="<?php echo esc_url( $base_url . '&subtab=general' ); ?>"
class="wp-bnb-subtab <?php echo 'general' === $active_subtab ? 'active' : ''; ?>">
<span class="dashicons dashicons-admin-generic"></span>
<?php esc_html_e( 'General', 'wp-bnb' ); ?>
</a>
<a href="<?php echo esc_url( $base_url . '&subtab=rate-limits' ); ?>"
class="wp-bnb-subtab <?php echo 'rate-limits' === $active_subtab ? 'active' : ''; ?>">
<span class="dashicons dashicons-dashboard"></span>
<?php esc_html_e( 'Rate Limits', 'wp-bnb' ); ?>
</a>
<a href="<?php echo esc_url( $base_url . '&subtab=endpoints' ); ?>"
class="wp-bnb-subtab <?php echo 'endpoints' === $active_subtab ? 'active' : ''; ?>">
<span class="dashicons dashicons-rest-api"></span>
<?php esc_html_e( 'Endpoints', 'wp-bnb' ); ?>
</a>
</div>
<form method="post" action="">
<?php wp_nonce_field( 'wp_bnb_save_settings', 'wp_bnb_settings_nonce' ); ?>
<?php if ( 'general' === $active_subtab ) : ?>
<!-- General Subtab -->
<h2><?php esc_html_e( 'REST API Settings', 'wp-bnb' ); ?></h2>
<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e( 'Enable API', 'wp-bnb' ); ?></th>
<td>
<label>
<input type="checkbox" name="wp_bnb_api_enabled" value="yes" <?php checked( $api_enabled, 'yes' ); ?>>
<?php esc_html_e( 'Enable the REST API endpoints', 'wp-bnb' ); ?>
</label>
<p class="description">
<?php esc_html_e( 'When enabled, external applications can access room, availability, and booking data via the API.', 'wp-bnb' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Rate Limiting', 'wp-bnb' ); ?></th>
<td>
<label>
<input type="checkbox" name="wp_bnb_api_rate_limiting" value="yes" <?php checked( $rate_limiting, 'yes' ); ?>>
<?php esc_html_e( 'Enable rate limiting', 'wp-bnb' ); ?>
</label>
<p class="description">
<?php esc_html_e( 'Limits API requests to prevent abuse. Recommended for production sites.', 'wp-bnb' ); ?>
<?php if ( 'yes' === $rate_limiting ) : ?>
<a href="<?php echo esc_url( $base_url . '&subtab=rate-limits' ); ?>"><?php esc_html_e( 'Configure limits', 'wp-bnb' ); ?> &rarr;</a>
<?php endif; ?>
</p>
</td>
</tr>
</table>
<h2 style="margin-top: 30px;"><?php esc_html_e( 'API Information', 'wp-bnb' ); ?></h2>
<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e( 'Base URL', 'wp-bnb' ); ?></th>
<td>
<code><?php echo esc_html( $api_base_url ); ?></code>
<p class="description">
<?php esc_html_e( 'All API endpoints are prefixed with this URL.', 'wp-bnb' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'API Version', 'wp-bnb' ); ?></th>
<td>
<code><?php echo esc_html( RestApi::VERSION ); ?></code>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Info Endpoint', 'wp-bnb' ); ?></th>
<td>
<code><?php echo esc_html( $api_base_url . '/info' ); ?></code>
<p class="description">
<?php esc_html_e( 'Returns API information and available endpoints.', 'wp-bnb' ); ?>
</p>
</td>
</tr>
</table>
<?php submit_button( __( 'Save Settings', 'wp-bnb' ) ); ?>
<?php elseif ( 'rate-limits' === $active_subtab ) : ?>
<!-- Rate Limits Subtab -->
<h2><?php esc_html_e( 'Rate Limit Configuration', 'wp-bnb' ); ?></h2>
<?php if ( 'yes' !== $rate_limiting ) : ?>
<div class="notice notice-warning inline" style="margin: 15px 0;">
<p>
<?php esc_html_e( 'Rate limiting is currently disabled.', 'wp-bnb' ); ?>
<a href="<?php echo esc_url( $base_url . '&subtab=general' ); ?>"><?php esc_html_e( 'Enable it in General settings', 'wp-bnb' ); ?></a>
</p>
</div>
<?php endif; ?>
<p class="description"><?php esc_html_e( 'Configure the number of requests allowed per time window for each endpoint type.', 'wp-bnb' ); ?></p>
<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e( 'Time Window', 'wp-bnb' ); ?></th>
<td>
<input type="number" name="wp_bnb_rate_limit_window" value="<?php echo esc_attr( $limit_window ); ?>" min="10" max="300" step="10" class="small-text">
<?php esc_html_e( 'seconds', 'wp-bnb' ); ?>
<p class="description">
<?php esc_html_e( 'The time window for rate limit counting. Default: 60 seconds.', 'wp-bnb' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Public Endpoints', 'wp-bnb' ); ?></th>
<td>
<input type="number" name="wp_bnb_rate_limit_public" value="<?php echo esc_attr( $limit_public ); ?>" min="1" max="1000" class="small-text">
<?php esc_html_e( 'requests per window', 'wp-bnb' ); ?>
<p class="description">
<?php
printf(
/* translators: %d: default limit */
esc_html__( 'Limit for public read endpoints (rooms, buildings, services). Default: %d', 'wp-bnb' ),
$defaults['public']
);
?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Availability Endpoints', 'wp-bnb' ); ?></th>
<td>
<input type="number" name="wp_bnb_rate_limit_availability" value="<?php echo esc_attr( $limit_avail ); ?>" min="1" max="1000" class="small-text">
<?php esc_html_e( 'requests per window', 'wp-bnb' ); ?>
<p class="description">
<?php
printf(
/* translators: %d: default limit */
esc_html__( 'Limit for availability checks and calendar requests. Default: %d', 'wp-bnb' ),
$defaults['availability']
);
?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Booking Endpoints', 'wp-bnb' ); ?></th>
<td>
<input type="number" name="wp_bnb_rate_limit_booking" value="<?php echo esc_attr( $limit_booking ); ?>" min="1" max="100" class="small-text">
<?php esc_html_e( 'requests per window', 'wp-bnb' ); ?>
<p class="description">
<?php
printf(
/* translators: %d: default limit */
esc_html__( 'Limit for booking creation. Keep low to prevent abuse. Default: %d', 'wp-bnb' ),
$defaults['booking']
);
?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Admin Endpoints', 'wp-bnb' ); ?></th>
<td>
<input type="number" name="wp_bnb_rate_limit_admin" value="<?php echo esc_attr( $limit_admin ); ?>" min="1" max="1000" class="small-text">
<?php esc_html_e( 'requests per window', 'wp-bnb' ); ?>
<p class="description">
<?php
printf(
/* translators: %d: default limit */
esc_html__( 'Limit for authenticated admin endpoints. Default: %d', 'wp-bnb' ),
$defaults['admin']
);
?>
</p>
</td>
</tr>
</table>
<h2 style="margin-top: 30px;"><?php esc_html_e( 'Current Rate Limits Summary', 'wp-bnb' ); ?></h2>
<table class="widefat striped" style="max-width: 600px;">
<thead>
<tr>
<th><?php esc_html_e( 'Type', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Limit', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Applies To', 'wp-bnb' ); ?></th>
</tr>
</thead>
<tbody>
<tr>
<td><?php esc_html_e( 'Public', 'wp-bnb' ); ?></td>
<td><strong><?php echo esc_html( $limit_public . '/' . $limit_window . 's' ); ?></strong></td>
<td><?php esc_html_e( 'GET rooms, buildings, services', 'wp-bnb' ); ?></td>
</tr>
<tr>
<td><?php esc_html_e( 'Availability', 'wp-bnb' ); ?></td>
<td><strong><?php echo esc_html( $limit_avail . '/' . $limit_window . 's' ); ?></strong></td>
<td><?php esc_html_e( 'Availability checks, calendar', 'wp-bnb' ); ?></td>
</tr>
<tr>
<td><?php esc_html_e( 'Booking', 'wp-bnb' ); ?></td>
<td><strong><?php echo esc_html( $limit_booking . '/' . $limit_window . 's' ); ?></strong></td>
<td><?php esc_html_e( 'Booking creation', 'wp-bnb' ); ?></td>
</tr>
<tr>
<td><?php esc_html_e( 'Admin', 'wp-bnb' ); ?></td>
<td><strong><?php echo esc_html( $limit_admin . '/' . $limit_window . 's' ); ?></strong></td>
<td><?php esc_html_e( 'All admin endpoints', 'wp-bnb' ); ?></td>
</tr>
</tbody>
</table>
<?php submit_button( __( 'Save Rate Limits', 'wp-bnb' ) ); ?>
<?php else : ?>
<!-- Endpoints Subtab -->
<h2><?php esc_html_e( 'Available Endpoints', 'wp-bnb' ); ?></h2>
<h3><?php esc_html_e( 'Public Endpoints', 'wp-bnb' ); ?></h3>
<p class="description"><?php esc_html_e( 'These endpoints are accessible without authentication.', 'wp-bnb' ); ?></p>
<table class="widefat striped" style="margin-bottom: 20px;">
<thead>
<tr>
<th style="width: 80px;"><?php esc_html_e( 'Method', 'wp-bnb' ); ?></th>
<th style="width: 250px;"><?php esc_html_e( 'Endpoint', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Description', 'wp-bnb' ); ?></th>
</tr>
</thead>
<tbody>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/buildings</code></td><td><?php esc_html_e( 'List all buildings', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/buildings/{id}</code></td><td><?php esc_html_e( 'Get building details', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/buildings/{id}/rooms</code></td><td><?php esc_html_e( 'List rooms in building', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/rooms</code></td><td><?php esc_html_e( 'List/search rooms', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/rooms/{id}</code></td><td><?php esc_html_e( 'Get room details', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/rooms/{id}/availability</code></td><td><?php esc_html_e( 'Check room availability', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/rooms/{id}/calendar</code></td><td><?php esc_html_e( 'Get room calendar', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method post">POST</span></td><td><code>/availability/search</code></td><td><?php esc_html_e( 'Search available rooms', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/services</code></td><td><?php esc_html_e( 'List services', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method post">POST</span></td><td><code>/pricing/calculate</code></td><td><?php esc_html_e( 'Calculate booking price', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method post">POST</span></td><td><code>/bookings</code></td><td><?php esc_html_e( 'Create booking (pending status)', 'wp-bnb' ); ?></td></tr>
</tbody>
</table>
<h3><?php esc_html_e( 'Admin Endpoints', 'wp-bnb' ); ?></h3>
<p class="description"><?php esc_html_e( 'These endpoints require authentication (Application Password or Cookie + Nonce).', 'wp-bnb' ); ?></p>
<table class="widefat striped" style="margin-bottom: 20px;">
<thead>
<tr>
<th style="width: 80px;"><?php esc_html_e( 'Method', 'wp-bnb' ); ?></th>
<th style="width: 250px;"><?php esc_html_e( 'Endpoint', 'wp-bnb' ); ?></th>
<th><?php esc_html_e( 'Description', 'wp-bnb' ); ?></th>
</tr>
</thead>
<tbody>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/bookings</code></td><td><?php esc_html_e( 'List all bookings', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/bookings/{id}</code></td><td><?php esc_html_e( 'Get booking details', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method patch">PATCH</span></td><td><code>/bookings/{id}</code></td><td><?php esc_html_e( 'Update booking', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method delete">DELETE</span></td><td><code>/bookings/{id}</code></td><td><?php esc_html_e( 'Cancel booking', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method post">POST</span></td><td><code>/bookings/{id}/confirm</code></td><td><?php esc_html_e( 'Confirm booking', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method post">POST</span></td><td><code>/bookings/{id}/check-in</code></td><td><?php esc_html_e( 'Check in guest', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method post">POST</span></td><td><code>/bookings/{id}/check-out</code></td><td><?php esc_html_e( 'Check out guest', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/guests</code></td><td><?php esc_html_e( 'List guests', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/guests/{id}</code></td><td><?php esc_html_e( 'Get guest details', 'wp-bnb' ); ?></td></tr>
<tr><td><span class="wp-bnb-method get">GET</span></td><td><code>/guests/search</code></td><td><?php esc_html_e( 'Search guests', 'wp-bnb' ); ?></td></tr>
</tbody>
</table>
<h2 style="margin-top: 30px;"><?php esc_html_e( 'Authentication', 'wp-bnb' ); ?></h2>
<p><?php esc_html_e( 'Admin endpoints require authentication. Use one of the following methods:', 'wp-bnb' ); ?></p>
<table class="form-table">
<tr>
<th scope="row">
<span class="dashicons dashicons-admin-network" style="color: #2271b1;"></span>
<?php esc_html_e( 'Application Passwords', 'wp-bnb' ); ?>
</th>
<td>
<p><?php esc_html_e( 'Create an Application Password in Users > Your Profile.', 'wp-bnb' ); ?></p>
<p><code>Authorization: Basic base64(username:app-password)</code></p>
<p class="description"><?php esc_html_e( 'Recommended for external applications and integrations.', 'wp-bnb' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<span class="dashicons dashicons-lock" style="color: #2271b1;"></span>
<?php esc_html_e( 'Cookie + Nonce', 'wp-bnb' ); ?>
</th>
<td>
<p><?php esc_html_e( 'For same-domain JavaScript requests when user is logged in.', 'wp-bnb' ); ?></p>
<p><code>X-WP-Nonce: <?php echo esc_html( wp_create_nonce( 'wp_rest' ) ); ?></code></p>
<p class="description"><?php esc_html_e( 'Best for frontend JavaScript that interacts with the API.', 'wp-bnb' ); ?></p>
</td>
</tr>
</table>
<?php endif; ?>
</form>
<?php
}
/**
* Render license status badge.
*
@@ -1507,6 +1851,9 @@ final class Plugin {
case 'metrics':
$this->save_metrics_settings();
break;
case 'api':
$this->save_api_settings();
break;
default:
$this->save_general_settings();
break;
@@ -1649,6 +1996,47 @@ 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 );
// Save rate limit configuration.
$defaults = \Magdev\WpBnb\Api\RateLimiter::get_default_limits();
$limit_window = isset( $_POST['wp_bnb_rate_limit_window'] )
? max( 10, min( 300, absint( $_POST['wp_bnb_rate_limit_window'] ) ) )
: 60;
$limit_public = isset( $_POST['wp_bnb_rate_limit_public'] )
? max( 1, min( 1000, absint( $_POST['wp_bnb_rate_limit_public'] ) ) )
: $defaults['public'];
$limit_avail = isset( $_POST['wp_bnb_rate_limit_availability'] )
? max( 1, min( 1000, absint( $_POST['wp_bnb_rate_limit_availability'] ) ) )
: $defaults['availability'];
$limit_booking = isset( $_POST['wp_bnb_rate_limit_booking'] )
? max( 1, min( 100, absint( $_POST['wp_bnb_rate_limit_booking'] ) ) )
: $defaults['booking'];
$limit_admin = isset( $_POST['wp_bnb_rate_limit_admin'] )
? max( 1, min( 1000, absint( $_POST['wp_bnb_rate_limit_admin'] ) ) )
: $defaults['admin'];
update_option( 'wp_bnb_rate_limit_window', $limit_window );
update_option( 'wp_bnb_rate_limit_public', $limit_public );
update_option( 'wp_bnb_rate_limit_availability', $limit_avail );
update_option( 'wp_bnb_rate_limit_booking', $limit_booking );
update_option( 'wp_bnb_rate_limit_admin', $limit_admin );
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.
*

View File

@@ -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.1
* 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.1' );
// Plugin path constants.
define( 'WP_BNB_PATH', plugin_dir_path( __FILE__ ) );