logAndReturn($apiKey, null, 'create_booking', $request); return $apiKey; } $params = $request->get_json_params(); if (empty($params)) { $params = $request->get_params(); } // 2. Validate required fields $errors = $this->validate($params); if (!empty($errors)) { $response = [ 'success' => false, 'error' => 'Validation failed', 'details' => $errors, ]; $this->logAndReturn($response, $apiKey, 'create_booking', $request, 400); return new \WP_REST_Response($response, 400); } // 3. Resolve restaurant $restaurantId = !empty($params['restaurant_id']) ? intval($params['restaurant_id']) : $apiKey->restaurant_id; $restaurant = Restaurant::find($restaurantId); if (!$restaurant) { $response = [ 'success' => false, 'error' => 'Restaurant not found', ]; $this->logAndReturn($response, $apiKey, 'create_booking', $request, 404); return new \WP_REST_Response($response, 404); } // 4. Parse time HH:MM to seconds $timeSeconds = $this->parseTime($params['time']); if ($timeSeconds === false) { $response = [ 'success' => false, 'error' => 'Invalid time format. Use HH:MM (e.g. 20:30)', ]; $this->logAndReturn($response, $apiKey, 'create_booking', $request, 400); return new \WP_REST_Response($response, 400); } // 5. Validate date $date = sanitize_text_field($params['date']); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { $response = [ 'success' => false, 'error' => 'Invalid date format. Use YYYY-MM-DD', ]; $this->logAndReturn($response, $apiKey, 'create_booking', $request, 400); return new \WP_REST_Response($response, 400); } // 6. Check for duplicate booking against the real bookings (same // restaurant+email+date+time+party still active). Cancelled/denied/ // no-show/deleted bookings are ignored so the same slot can be booked // again. $existing = $this->findActiveDuplicate($restaurantId, sanitize_email($params['email']), $date, $timeSeconds, intval($params['party'])); if ($existing) { $response = [ 'success' => true, 'booking_id' => $existing->id, 'uuid' => $existing->uuid, 'status' => $existing->status, 'duplicate' => true, ]; $this->logAndReturn($response, $apiKey, 'create_booking_duplicate', $request, 200); return new \WP_REST_Response($response, 200); } // 7. Resolve service (shift/event) $serviceId = !empty($params['service_id']) ? intval($params['service_id']) : null; $service = null; $serviceName = 'Unknown'; if ($serviceId) { $service = Shift::where('id', $serviceId)->first(); if (!$service) { $service = Event::where('id', $serviceId)->first(); } } else { $service = $this->resolveService($restaurantId, $date, $timeSeconds); } if ($service) { $serviceId = $service->id; $serviceName = $service->name; } // 8. Resolve duration from service $duration = $this->resolveDuration($service, intval($params['party'])); // 9. Validate status $status = !empty($params['status']) ? sanitize_text_field($params['status']) : BookingStatus::BOOKED; $allowedStatuses = [BookingStatus::PENDING, BookingStatus::BOOKED]; if (!in_array($status, $allowedStatuses)) { $response = [ 'success' => false, 'error' => 'Invalid status. Allowed values: pending, booked', ]; $this->logAndReturn($response, $apiKey, 'create_booking', $request, 400); return new \WP_REST_Response($response, 400); } // 10. Resolve platform / source $platform = !empty($params['platform']) ? sanitize_text_field($params['platform']) : ($apiKey->platform ?: 'API'); // 11. Find or create customer $email = sanitize_email($params['email']); $firstName = sanitize_text_field($params['first_name']); $lastName = isset($params['last_name']) ? sanitize_text_field($params['last_name']) : ''; $phone = isset($params['phone']) ? sanitize_text_field($params['phone']) : ''; $customer = Customer::where('restaurant_id', $restaurantId) ->where('email', $email) ->first(); // 12. Create booking $booking = new Booking(); $booking->restaurant_id = $restaurantId; $booking->source = $platform; $booking->platform = substr($platform, 0, 20); $booking->platform_booking_id = isset($params['platform_booking_id']) ? substr(sanitize_text_field($params['platform_booking_id']), 0, 50) : null; $booking->type = 'online'; $booking->status = $status; $booking->date = $date; $booking->time = $timeSeconds; $booking->party = intval($params['party']); $booking->duration = $duration; $booking->shift_event_id = $serviceId; $booking->shift_event_name = $serviceName; $booking->first_name = $firstName; $booking->last_name = $lastName; $booking->email = $email; $booking->phone = $phone; $booking->language = $restaurant->language ?: 'en'; $booking->notes = isset($params['notes']) ? sanitize_textarea_field($params['notes']) : null; $booking->agree_receive_email = 1; $booking->agree_receive_sms = 1; if ($customer) { $booking->customer_id = $customer->id; } $booking->save(); // Create customer if not found if (!$customer) { $customer = alexr_create_new_customer($booking); if ($customer) { $booking->customer_id = $customer->id; $booking->save(); } } // 13. Fire event evavel_event(new EventBookingCreated($booking)); // 14. Send notifications if requested $sendNotifications = !empty($params['send_notifications']) && $params['send_notifications'] === true; if ($sendNotifications) { $booking->sendBookingEmail(); $booking->sendSmsNotification(); $booking->sendWhatsappNotification(); $booking->restaurant->notify(new \Alexr\Notifications\BookingOnlineReceived($booking)); } // 15. Build response $response = [ 'success' => true, 'booking_id' => $booking->id, 'uuid' => $booking->uuid, 'status' => $booking->status, ]; $this->logAndReturn($response, $apiKey, 'create_booking', $request, 201); return new \WP_REST_Response($response, 201); } /** * Handle GET /wp-json/alexr/v1/bookings/{uuid}/status */ public function getBookingStatus(\WP_REST_Request $request) { // 1. Authenticate $apiKey = ApiKeyAuth::authenticate($request); if (is_wp_error($apiKey)) { $this->logAndReturn($apiKey, null, 'get_booking_status', $request); return $apiKey; } // 2. Get booking by UUID $uuid = sanitize_text_field($request->get_param('uuid')); $booking = Booking::where('uuid', $uuid)->first(); if (!$booking) { $response = [ 'success' => false, 'error' => 'Booking not found', ]; $this->logAndReturn($response, $apiKey, 'get_booking_status', $request, 404); return new \WP_REST_Response($response, 404); } // 3. Verify the booking belongs to the API key's restaurant if ($booking->restaurant_id != $apiKey->restaurant_id) { $response = [ 'success' => false, 'error' => 'Booking not found', ]; $this->logAndReturn($response, $apiKey, 'get_booking_status', $request, 404); return new \WP_REST_Response($response, 404); } // 4. Build response $response = [ 'success' => true, 'booking_id' => $booking->id, 'uuid' => $booking->uuid, 'status' => $booking->status, 'platform' => $booking->platform, 'platform_booking_id' => $booking->platform_booking_id, ]; $this->logAndReturn($response, $apiKey, 'get_booking_status', $request, 200); return new \WP_REST_Response($response, 200); } /** * Handle POST /wp-json/alexr/v1/bookings/{uuid}/cancel */ public function cancelBooking(\WP_REST_Request $request) { // 1. Authenticate $apiKey = ApiKeyAuth::authenticate($request); if (is_wp_error($apiKey)) { $this->logAndReturn($apiKey, null, 'cancel_booking', $request); return $apiKey; } // 2. Get booking by UUID $uuid = sanitize_text_field($request->get_param('uuid')); $booking = Booking::where('uuid', $uuid)->first(); if (!$booking) { $response = [ 'success' => false, 'error' => 'Booking not found', ]; $this->logAndReturn($response, $apiKey, 'cancel_booking', $request, 404); return new \WP_REST_Response($response, 404); } // 3. Verify the booking belongs to the API key's restaurant if ($booking->restaurant_id != $apiKey->restaurant_id) { $response = [ 'success' => false, 'error' => 'Booking not found', ]; $this->logAndReturn($response, $apiKey, 'cancel_booking', $request, 404); return new \WP_REST_Response($response, 404); } // 4. Only allow cancellation if booking came from a platform if (empty($booking->platform)) { $response = [ 'success' => false, 'error' => 'Only bookings created from an external platform can be cancelled via API', ]; $this->logAndReturn($response, $apiKey, 'cancel_booking', $request, 403); return new \WP_REST_Response($response, 403); } // 5. Check if already cancelled if ($booking->status === BookingStatus::CANCELLED) { $response = [ 'success' => true, 'message' => 'Booking is already cancelled', 'booking_id' => $booking->id, 'uuid' => $booking->uuid, 'status' => $booking->status, 'platform' => $booking->platform, 'platform_booking_id' => $booking->platform_booking_id, ]; $this->logAndReturn($response, $apiKey, 'cancel_booking', $request, 200); return new \WP_REST_Response($response, 200); } // 6. Cancel the booking $old_status = $booking->status; $booking->status = BookingStatus::CANCELLED; $booking->save(); // 7. Fire status changed event // @TODO. No tiene usuario y da error //evavel_event(new EventBookingStatusChanged($booking, $old_status, BookingStatus::CANCELLED, null)); // 8. Send notifications if requested $params = $request->get_json_params(); if (empty($params)) { $params = $request->get_params(); } $sendNotifications = !empty($params['send_notifications']) && $params['send_notifications'] === true; if ($sendNotifications) { $booking->sendBookingEmail(); $booking->sendSmsNotification(); $booking->sendWhatsappNotification(); } // 9. Build response $response = [ 'success' => true, 'booking_id' => $booking->id, 'uuid' => $booking->uuid, 'status' => $booking->status, 'platform' => $booking->platform, 'platform_booking_id' => $booking->platform_booking_id, ]; $this->logAndReturn($response, $apiKey, 'cancel_booking', $request, 200); return new \WP_REST_Response($response, 200); } /** * Validate required fields */ protected function validate($params) { $errors = []; if (empty($params['first_name'])) { $errors['first_name'] = 'first_name is required'; } if (empty($params['email'])) { $errors['email'] = 'email is required'; } elseif (!is_email($params['email'])) { $errors['email'] = 'Invalid email format'; } if (empty($params['date'])) { $errors['date'] = 'date is required (YYYY-MM-DD)'; } if (empty($params['time'])) { $errors['time'] = 'time is required (HH:MM)'; } if (empty($params['party']) || intval($params['party']) < 1) { $errors['party'] = 'party is required and must be >= 1'; } return $errors; } /** * Parse time string "HH:MM" to seconds since midnight */ protected function parseTime($time) { $time = trim($time); if (!preg_match('/^(\d{1,2}):(\d{2})$/', $time, $matches)) { return false; } $hours = intval($matches[1]); $minutes = intval($matches[2]); if ($hours > 23 || $minutes > 59) { return false; } return ($hours * 3600) + ($minutes * 60); } /** * Try to find the shift/event active for the given restaurant, date and time */ protected function resolveService($restaurantId, $date, $timeSeconds) { // Get day of week abbreviation $dayOfWeek = strtolower(date('D', strtotime($date))); // mon, tue, wed, etc. // Search shifts for this restaurant $shifts = Shift::where('restaurant_id', $restaurantId)->get(); foreach ($shifts as $shift) { if (!$shift->active) { continue; } $daysOfWeek = $shift->days_of_week; if (!is_array($daysOfWeek) || empty($daysOfWeek[$dayOfWeek]) || $daysOfWeek[$dayOfWeek] === false) { continue; } // Check if time falls within this shift's seating range // Check day-specific hours first, then fallback to general $workingHoursKey = 'working_hours_' . $dayOfWeek; $hasCustomHours = !empty($shift->$workingHoursKey); if ($hasCustomHours) { $firstSeating = intval($shift->{'first_seating_' . $dayOfWeek}); $lastSeating = intval($shift->{'last_seating_' . $dayOfWeek}); } else { $firstSeating = intval($shift->first_seating); $lastSeating = intval($shift->last_seating); } if ($timeSeconds >= $firstSeating && $timeSeconds <= $lastSeating) { return $shift; } } // Also check events $events = Event::where('restaurant_id', $restaurantId)->get(); foreach ($events as $event) { if (!$event->active) { continue; } $firstSeating = intval($event->first_seating); $lastSeating = intval($event->last_seating); if ($timeSeconds >= $firstSeating && $timeSeconds <= $lastSeating) { return $event; } } return null; } /** * Resolve booking duration from the service configuration */ protected function resolveDuration($service, $party) { $defaultDuration = 5400; // 1.5 hours default if (!$service) { return $defaultDuration; } $durationMode = $service->duration_mode ?? 'time'; if ($durationMode === 'covers') { $durationCovers = $service->duration_covers; if (is_array($durationCovers)) { // Find the matching duration for the party size // duration_covers is array of ['label' => covers, 'value' => seconds] $lastValue = $defaultDuration; foreach ($durationCovers as $entry) { if (intval($entry['label']) <= $party) { $lastValue = intval($entry['value']); } } return $lastValue; } } // Mode 'time' or fallback return intval($service->duration_time ?? $defaultDuration); } /** * Most recent active booking with the same restaurant+email+date+time+party, * or null. Cancelled, denied, no-show, deleted and unfinished (selected) * bookings do not count as duplicates. * * @return Booking|null */ protected function findActiveDuplicate($restaurantId, $email, $date, $timeSeconds, $party) { return Booking::where('restaurant_id', intval($restaurantId)) ->where('email', $email) ->where('date', $date) ->where('time', intval($timeSeconds)) ->where('party', intval($party)) ->where('status', '!=', BookingStatus::SELECTED) ->where('status', '!=', BookingStatus::CANCELLED) ->where('status', '!=', BookingStatus::DENIED) ->where('status', '!=', BookingStatus::NO_SHOW) ->where('status', '!=', BookingStatus::DELETED) ->orderBy('id', 'desc') ->first(); } /** * Log the API request/response */ protected function logAndReturn($response, $apiKey, $action, $request, $statusCode = null, $idempotencyKey = null) { if (is_wp_error($response)) { $statusCode = $statusCode ?: $response->get_error_data()['status'] ?? 500; $responseBody = [ 'success' => false, 'error' => $response->get_error_message(), ]; } else { $responseBody = $response; } $requestBody = $request->get_json_params(); if (empty($requestBody)) { $requestBody = $request->get_params(); } // Remove sensitive data from log unset($requestBody['api_key']); ApiLog::log($apiKey, $action, $requestBody, $responseBody, $statusCode, $idempotencyKey); } }