authenticate($request, $action); if ($apiKey instanceof \WP_REST_Response) return $apiKey; $restaurant = Restaurant::where('active', 1)->where('id', $apiKey->restaurant_id)->first(); if (!$restaurant) { return $this->errorResponse($apiKey, $action, $request, 'RESTAURANT_NOT_FOUND', 'Restaurant not found or inactive.', 404); } $widgetId = $this->resolveWidgetId($request, $apiKey); if (!$widgetId) { return $this->errorResponse($apiKey, $action, $request, 'WIDGET_NOT_FOUND', 'No widget_id configured.', 400); } $widget = WidgetForm::where('id', $widgetId)->where('restaurant_id', $restaurant->id)->first(); if (!$widget) { return $this->errorResponse($apiKey, $action, $request, 'WIDGET_NOT_FOUND', 'Widget not found for this restaurant.', 404); } $params = $this->getParams($request); // Validation $errors = $this->validateCreate($params); if (!empty($errors)) { return $this->errorResponse($apiKey, $action, $request, 'VALIDATION_FAILED', 'Validation failed.', 400, $errors); } $date = $this->parseDate($params['date']); if (!$date) { return $this->errorResponse($apiKey, $action, $request, 'INVALID_DATE', 'Invalid date. Use YYYY-MM-DD.', 400); } $timeSeconds = $this->parseTime($params['time']); if ($timeSeconds === false) { return $this->errorResponse($apiKey, $action, $request, 'INVALID_TIME', 'Invalid time. Use HH:MM (24h).', 400); } $party = intval($params['party_size']); $firstName = sanitize_text_field($params['customer_name']); $lastName = ''; if (isset($params['customer_last_name'])) { $lastName = sanitize_text_field($params['customer_last_name']); } $phone = sanitize_text_field($params['customer_phone']); $dialCode = isset($params['customer_dial_code']) ? sanitize_text_field($params['customer_dial_code']) : ''; // Email: optional, generate synthetic if missing $email = isset($params['customer_email']) ? sanitize_email($params['customer_email']) : ''; if (empty($email)) { $email = $this->buildSyntheticEmail($apiKey->platform, $phone, $dialCode); } elseif (!is_email($email)) { return $this->errorResponse($apiKey, $action, $request, 'VALIDATION_FAILED', 'Invalid email format.', 400); } // Date closed? if ($restaurant->isDateClosed($date)) { return $this->errorResponse($apiKey, $action, $request, 'DATE_CLOSED', 'Sorry, this date is closed for reservations.', 409); } // Service resolution $serviceIdParam = isset($params['service_id']) ? $params['service_id'] : null; $service = $this->resolveService($restaurant, $widgetId, $serviceIdParam, $date, $timeSeconds); if (!$service) { return $this->errorResponse($apiKey, $action, $request, 'SERVICE_NOT_FOUND', 'No service (shift/event) found for this date and time on the given widget.', 404); } // Optional explicit table assignment (POS walk-ins): when table_ids are // given, AR does not auto-assign and — since the guests are physically // seated — the availability/online-bookings gates are skipped. $forcedTableIds = $this->resolveTableIds($params, $restaurant->id, $apiKey, $action, $request); if ($forcedTableIds instanceof \WP_REST_Response) return $forcedTableIds; $forceTables = is_array($forcedTableIds) && !empty($forcedTableIds); // Optional area (id or name), igual que el "El usuario puede seleccionar // Area" del widget $area = $this->resolveArea($params, $restaurant->id, $apiKey, $action, $request); if ($area instanceof \WP_REST_Response) return $area; if ($area && !$forceTables) { $areaCheck = $this->validateAreaForService($service, $area, $date, $timeSeconds, $party, $apiKey, $action, $request); if ($areaCheck instanceof \WP_REST_Response) return $areaCheck; } // Slot closed? if ($service->isSlotClosed($date, $timeSeconds)) { return $this->errorResponse($apiKey, $action, $request, 'SLOT_CLOSED', 'This time slot is closed.', 409); } // Service must be enabled for online bookings on this date (skipped for a // forced walk-in — it is not an online booking). if (!$forceTables && !$restaurant->isOnlineBookingsEnabled($service->id, $date)) { return $this->errorResponse($apiKey, $action, $request, 'SLOT_UNAVAILABLE', 'Online bookings are disabled for this service on the given date.', 409); } // Duplicate check against the real bookings (same restaurant+email+date+time+party // still active). Cancelled/denied/no-show/deleted bookings are ignored so the // customer can book the same slot again. $existing = $this->findActiveDuplicate($restaurant->id, $email, $date, $timeSeconds, $party); if ($existing) { return $this->successResponse($apiKey, $action, $request, $this->serializeBooking($existing, ['duplicate' => true]), 200); } // Real availability check (skipped when explicit tables are forced). $isAvailable = true; if (!$forceTables) { $isAvailable = $service->isAvailable($date, $timeSeconds, $party); if (!$isAvailable) { $avail = new BotAvailabilityController(); $alternatives = $avail->findAlternativeDates($restaurant, $widgetId, $date, $party, $service); return $this->errorResponse( $apiKey, $action, $request, 'SLOT_UNAVAILABLE', 'No availability for the requested date, time and party size.', 409, ['alternative_dates' => $alternatives] ); } // Servicio por mesas con area pedida: las mesas tienen que salir de // esa area. isAvailable() ya ha pasado las comprobaciones generales // (horario, limites por slot), aqui solo se acota la busqueda. if ($area && $service->areaSelectionMode() === 'tables') { $areaTables = $service->isAvailableBy_SearchTables($date, $timeSeconds, $party, false, $area->id); if (empty($areaTables)) { return $this->errorResponse($apiKey, $action, $request, 'AREA_FULL', 'No free table in this area for the requested date, time and party size.', 409, ['area_id' => intval($area->id), 'area_name' => $area->name]); } $isAvailable = $areaTables; } } // Resolve customer (existing or new) $customer = Customer::where('restaurant_id', $restaurant->id) ->where('email', $email) ->first(); // Create booking $booking = new Booking(); $booking->restaurant_id = $restaurant->id; $booking->widget_id = $widget->id; $booking->shift_event_id = $service->id; $booking->shift_event_name = $service->name; $booking->status = BookingStatus::SELECTED; // temporary until details saved $booking->date = $date; $booking->time = $timeSeconds; $booking->party = $party; $booking->duration = $service->getDuration($party); $booking->first_name = $firstName; $booking->last_name = $lastName; $booking->email = $email; $booking->phone = $phone; $booking->dial_code = $dialCode; $booking->type = BookingType::ONLINE; $booking->source = $apiKey->platform ?: 'bot'; $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; // Area elegida, mismo campo que guarda el widget if ($area) { $booking->area_selected_id = $area->id; } if ($customer) { $booking->customer_id = $customer->id; } $booking->save(); // Attach tables: the explicit (forced) ones, or those isAvailable returned. if ($forceTables) { $booking->tables()->sync($forcedTableIds); } else if (is_array($isAvailable) && !empty($isAvailable)) { $autoIds = array_map(function($t){ return $t['id']; }, $isAvailable); $booking->tables()->sync($autoIds); } // Determine final status. // Priority: explicit `status` param (bot decides) > service rules > pending-for-specific-tables rule. $statusOverride = isset($params['status']) ? sanitize_text_field($params['status']) : ''; $statusAllowed = [BookingStatus::PENDING, BookingStatus::BOOKED, BookingStatus::CONFIRMED]; if ($statusOverride !== '') { if (!in_array($statusOverride, $statusAllowed, true)) { return $this->errorResponse($apiKey, $action, $request, 'VALIDATION_FAILED', 'status must be one of: pending, booked, confirmed.', 400, ['allowed' => $statusAllowed]); } $booking->status = $statusOverride; // Explicit override bypasses the "force pending for specific tables" rule — // if the caller says booked/confirmed, respect it. } else { $booking->status = $service->getBookingStatusForNewReservation($date, $timeSeconds, $party, $email); $service->applyRulePendingForSpecificTables($booking); } $booking->save(); // Create customer if it didn't exist if (!$customer) { $customer = alexr_create_new_customer($booking); if ($customer) { $booking->customer_id = $customer->id; $booking->save(); } } // Fire created event evavel_event(new EventBookingCreated($booking)); // Notifications (default ON) $sendNotifications = !isset($params['send_notifications']) || $params['send_notifications'] !== false; if ($sendNotifications) { $this->sendCreationNotifications($booking); } $data = $this->serializeBooking($booking); return $this->successResponse($apiKey, $action, $request, $data, 201); } /** * GET /bookings/{uuid} */ public function read(\WP_REST_Request $request) { $action = 'bot_get_booking'; $apiKey = $this->authenticate($request, $action); if ($apiKey instanceof \WP_REST_Response) return $apiKey; $uuid = sanitize_text_field($request->get_param('uuid')); $booking = Booking::where('uuid', $uuid) ->where('status', '!=', BookingStatus::DELETED) ->where('status', '!=', BookingStatus::SELECTED) ->first(); if (!$booking || $booking->restaurant_id != $apiKey->restaurant_id) { return $this->errorResponse($apiKey, $action, $request, 'BOOKING_NOT_FOUND', 'Booking not found.', 404); } return $this->successResponse($apiKey, $action, $request, $this->serializeBooking($booking), 200); } /** * GET /bookings?phone=...&limit=... Search a guest's bookings by phone. * GET /bookings?date=YYYY-MM-DD List ALL bookings for that day. */ public function search(\WP_REST_Request $request) { $action = 'bot_search_bookings'; $apiKey = $this->authenticate($request, $action); if ($apiKey instanceof \WP_REST_Response) return $apiKey; // List-by-date takes precedence: returns every booking of the day so the // caller can see which tables are already taken/reserved. $date = sanitize_text_field($request->get_param('date')); if (!empty($date)) { return $this->listByDate($apiKey, $action, $request, $date); } $phone = sanitize_text_field($request->get_param('phone')); if (empty($phone)) { return $this->errorResponse($apiKey, $action, $request, 'VALIDATION_FAILED', 'phone or date query parameter is required.', 400); } $limit = intval($request->get_param('limit')) ?: 5; $limit = max(1, min($limit, 20)); $includePast = $request->get_param('include_past'); $includePast = ($includePast === true || $includePast === '1' || $includePast === 'true'); $query = Booking::where('restaurant_id', $apiKey->restaurant_id) ->where('phone', $phone) ->where('status', '!=', BookingStatus::DELETED) ->where('status', '!=', BookingStatus::SELECTED); if (!$includePast) { $today = evavel_now_timezone_formatted(Restaurant::find($apiKey->restaurant_id)->timezone, 'Y-m-d'); $query->where('date', '>=', $today); } // Only ONE orderBy in SQL: the query builder emits a separate "ORDER BY" // per orderBy() call, so chaining two breaks the SQL. Order by date for // the limit, then sort the secondary key (time) in PHP below. $bookings = $query->orderBy('date', 'desc') ->limit($limit) ->get(); $list = []; foreach ($bookings as $booking) { $list[] = $this->serializeBooking($booking); } // Most recent first: date desc, then time desc. usort($list, function ($a, $b) { if ($a['date'] !== $b['date']) { return strcmp($b['date'], $a['date']); } return $b['time_seconds'] - $a['time_seconds']; }); $data = [ 'count' => count($list), 'bookings' => $list, ]; return $this->successResponse($apiKey, $action, $request, $data, 200); } /** * GET /bookings?date=YYYY-MM-DD * * Lists every booking for the given day (all statuses except the internal * SELECTED and DELETED), each with its assigned table(s). Same per-booking * structure as GET /bookings/{uuid}. * * @param ApiKeyBot $apiKey * @param string $action * @param \WP_REST_Request $request * @param string $date raw YYYY-MM-DD * @return \WP_REST_Response */ protected function listByDate($apiKey, $action, $request, $date) { $parsed = $this->parseDate($date); if ($parsed === false) { return $this->errorResponse($apiKey, $action, $request, 'VALIDATION_FAILED', 'date must be a valid date in YYYY-MM-DD format.', 400); } // Tables are loaded lazily per booking when serialized (the framework's // eager-loader only supports belongsTo, not the belongsToMany tables()). $bookings = Booking::where('restaurant_id', $apiKey->restaurant_id) ->where('date', $parsed) ->where('status', '!=', BookingStatus::DELETED) ->where('status', '!=', BookingStatus::SELECTED) ->orderBy('time', 'asc') ->get(); $data = [ 'date' => $parsed, 'count' => is_countable($bookings) ? count($bookings) : 0, 'bookings' => [], ]; foreach ($bookings as $booking) { $data['bookings'][] = $this->serializeBooking($booking); } return $this->successResponse($apiKey, $action, $request, $data, 200); } /** * PATCH/PUT /bookings/{uuid} */ public function update(\WP_REST_Request $request) { $action = 'bot_modify_booking'; $apiKey = $this->authenticate($request, $action); if ($apiKey instanceof \WP_REST_Response) return $apiKey; $uuid = sanitize_text_field($request->get_param('uuid')); $booking = Booking::where('uuid', $uuid) ->where('status', '!=', BookingStatus::DELETED) ->first(); if (!$booking || $booking->restaurant_id != $apiKey->restaurant_id) { return $this->errorResponse($apiKey, $action, $request, 'BOOKING_NOT_FOUND', 'Booking not found.', 404); } // Can't modify cancelled/denied/no-show/finished bookings $nonModifiable = [BookingStatus::CANCELLED, BookingStatus::DENIED, BookingStatus::NO_SHOW, BookingStatus::FINISHED, BookingStatus::PAID]; if (in_array($booking->status, $nonModifiable, true)) { return $this->errorResponse($apiKey, $action, $request, 'BOOKING_NOT_MODIFIABLE', 'This booking cannot be modified given its current status.', 409, ['status' => $booking->status]); } $restaurant = Restaurant::find($booking->restaurant_id); $service = alexr_get_service($booking->shift_event_id); if (!$restaurant || !$service) { return $this->errorResponse($apiKey, $action, $request, 'BOOKING_NOT_MODIFIABLE', 'Booking is missing its restaurant or service.', 409); } $params = $this->getParams($request); // Snapshot original values $oldAttrs = method_exists(\Alexr\Listeners\ListenBookingEvents::class, 'getOriginal') ? \Alexr\Listeners\ListenBookingEvents::getOriginal($booking) : $booking->toArray(); $oldDate = $booking->date; $oldTime = $booking->time; $oldParty = $booking->party; $oldTablesList = $booking->tablesList; // Optional explicit table assignment (POS table move / walk-in re-seat). // When given it overrides AR's auto-assignment and is applied even when // the date/time/party do not change. $forcedTableIds = $this->resolveTableIds($params, $booking->restaurant_id, $apiKey, $action, $request); if ($forcedTableIds instanceof \WP_REST_Response) return $forcedTableIds; // Resolve new values, falling back to current ones $newDate = $oldDate; if (isset($params['date'])) { $newDate = $this->parseDate($params['date']); if (!$newDate) { return $this->errorResponse($apiKey, $action, $request, 'INVALID_DATE', 'Invalid date.', 400); } } $newTime = $oldTime; if (isset($params['time'])) { $newTime = $this->parseTime($params['time']); if ($newTime === false) { return $this->errorResponse($apiKey, $action, $request, 'INVALID_TIME', 'Invalid time.', 400); } } $newParty = $oldParty; if (isset($params['party_size'])) { $newParty = intval($params['party_size']); if ($newParty < 1) { return $this->errorResponse($apiKey, $action, $request, 'VALIDATION_FAILED', 'party_size must be >= 1.', 400); } } $slotChanged = ($newDate !== $oldDate) || ($newTime != $oldTime) || ($newParty !== intval($oldParty)); if ($slotChanged) { if ($restaurant->isDateClosed($newDate)) { return $this->errorResponse($apiKey, $action, $request, 'DATE_CLOSED', 'Date is closed.', 409); } if ($service->isSlotClosed($newDate, $newTime)) { return $this->errorResponse($apiKey, $action, $request, 'SLOT_CLOSED', 'Slot is closed.', 409); } $isAvailable = $service->isAvailable($newDate, $newTime, $newParty, $booking->id); if (!$isAvailable) { $avail = new BotAvailabilityController(); $alt = $avail->findAlternativeDates($restaurant, $booking->widget_id, $newDate, $newParty, $service); return $this->errorResponse( $apiKey, $action, $request, 'SLOT_UNAVAILABLE', 'The requested change conflicts with current availability.', 409, ['alternative_dates' => $alt] ); } $booking->date = $newDate; $booking->time = $newTime; $booking->party = $newParty; $booking->duration = $service->getDuration($newParty); // Auto-assign tables only when the caller did NOT force specific ones. if ($forcedTableIds === null && is_array($isAvailable) && !empty($isAvailable)) { $autoIds = array_map(function($t){ return $t['id']; }, $isAvailable); $booking->tables()->sync($autoIds); } } // Area: solo se toca si la mandan. No se revalida por cuenta propia al // cambiar comensales u hora; si el bot quiere reubicar la reserva que // mande el area junto al cambio. if (array_key_exists('area', $params)) { // area: null o "" limpia el area elegida if ($params['area'] === null || $params['area'] === '') { $booking->area_selected_id = null; } else { $area = $this->resolveArea($params, $booking->restaurant_id, $apiKey, $action, $request); if ($area instanceof \WP_REST_Response) return $area; $areaCheck = $this->validateAreaForService($service, $area, $booking->date, $booking->time, $booking->party, $apiKey, $action, $request); if ($areaCheck instanceof \WP_REST_Response) return $areaCheck; // Servicio por mesas: reasignar dentro del area pedida if ($forcedTableIds === null && $service->areaSelectionMode() === 'tables') { $areaTables = $service->isAvailableBy_SearchTables( $booking->date, $booking->time, $booking->party, false, $area->id, $booking->id ); if (empty($areaTables)) { return $this->errorResponse($apiKey, $action, $request, 'AREA_FULL', 'No free table in this area for this booking.', 409, ['area_id' => intval($area->id), 'area_name' => $area->name]); } $booking->tables()->sync(array_map(function ($t) { return $t['id']; }, $areaTables)); } $booking->area_selected_id = $area->id; } } // Optional non-availability fields if (isset($params['notes'])) { $booking->notes = sanitize_textarea_field($params['notes']); } if (isset($params['customer_name'])) { $booking->first_name = sanitize_text_field($params['customer_name']); } if (isset($params['customer_last_name'])) { $booking->last_name = sanitize_text_field($params['customer_last_name']); } if (isset($params['customer_phone'])) { $booking->phone = sanitize_text_field($params['customer_phone']); } if (isset($params['customer_email'])) { $newEmail = sanitize_email($params['customer_email']); if ($newEmail && is_email($newEmail)) { $booking->email = $newEmail; } } $booking->save(); // Apply explicit tables (overrides auto-assignment), even with no slot // change (e.g. a waiter moving the reservation to another table). Log the // table change for the audit trail / floor plan. if ($forcedTableIds !== null) { $booking->tables()->sync($forcedTableIds); $oldSorted = $oldTablesList; $newSorted = array_map('intval', $forcedTableIds); sort($oldSorted); sort($newSorted); if ($oldSorted !== $newSorted) { evavel_event(new EventBookingTablesChanged($booking, $oldTablesList, array_map('intval', $forcedTableIds), null)); } } evavel_event(new EventBookingModified($booking, null, $oldAttrs)); $sendNotifications = !isset($params['send_notifications']) || $params['send_notifications'] !== false; if ($sendNotifications) { $this->sendModificationNotifications($booking); } return $this->successResponse($apiKey, $action, $request, $this->serializeBooking($booking, [ 'old_date' => $oldDate, 'old_time' => intval($oldTime), 'old_party' => intval($oldParty), ]), 200); } /** * POST /bookings/{uuid}/cancel */ public function cancel(\WP_REST_Request $request) { $action = 'bot_cancel_booking'; $apiKey = $this->authenticate($request, $action); if ($apiKey instanceof \WP_REST_Response) return $apiKey; $uuid = sanitize_text_field($request->get_param('uuid')); $booking = Booking::where('uuid', $uuid) ->where('status', '!=', BookingStatus::DELETED) ->first(); if (!$booking || $booking->restaurant_id != $apiKey->restaurant_id) { return $this->errorResponse($apiKey, $action, $request, 'BOOKING_NOT_FOUND', 'Booking not found.', 404); } if ($booking->status === BookingStatus::CANCELLED) { return $this->successResponse($apiKey, $action, $request, array_merge( $this->serializeBooking($booking), ['message' => 'Booking is already cancelled.'] ), 200); } $params = $this->getParams($request); $oldStatus = $booking->status; $booking->status = BookingStatus::CANCELLED; if (isset($params['reason'])) { $booking->cancelReason = sanitize_text_field($params['reason']); } $booking->save(); evavel_event(new EventBookingStatusChangedByCustomer($booking, $oldStatus, BookingStatus::CANCELLED)); $sendNotifications = !isset($params['send_notifications']) || $params['send_notifications'] !== false; if ($sendNotifications) { $this->sendCancellationNotifications($booking); } return $this->successResponse($apiKey, $action, $request, $this->serializeBooking($booking), 200); } /** * PATCH/PUT /bookings/{uuid}/status * * Status change driven by the POS / host app or by an automated bot flow. * Supports both reservation-lifecycle transitions (pending → booked → confirmed) * and table-lifecycle transitions (seated → finished, or no-show). * * By default no customer notifications are sent (same as the dashboard status * change); pass `send_notifications: true` to send the appropriate email/SMS/ * WhatsApp when the new status is `pending`, `booked` or `cancelled` (though * cancellations should preferably go through POST /bookings/{uuid}/cancel). */ public function updateStatus(\WP_REST_Request $request) { $action = 'bot_update_status'; $apiKey = $this->authenticate($request, $action); if ($apiKey instanceof \WP_REST_Response) return $apiKey; $uuid = sanitize_text_field($request->get_param('uuid')); $booking = Booking::where('uuid', $uuid) ->where('status', '!=', BookingStatus::DELETED) ->first(); if (!$booking || $booking->restaurant_id != $apiKey->restaurant_id) { return $this->errorResponse($apiKey, $action, $request, 'BOOKING_NOT_FOUND', 'Booking not found.', 404); } $params = $this->getParams($request); $newStatus = isset($params['status']) ? sanitize_text_field($params['status']) : ''; // Statuses the bot / POS / host app may set through this endpoint. // Cancellation should go through POST /bookings/{uuid}/cancel (with reason). $allowed = [ BookingStatus::PENDING, // awaiting restaurant confirmation BookingStatus::BOOKED, // restaurant confirmed BookingStatus::CONFIRMED, // customer re-confirmed BookingStatus::SEATED, // guests have been seated BookingStatus::FINISHED, // table closed / paid out BookingStatus::NO_SHOW, // guests did not show up ]; if (!in_array($newStatus, $allowed, true)) { return $this->errorResponse($apiKey, $action, $request, 'VALIDATION_FAILED', 'status must be one of: pending, booked, confirmed, seated, finished, no-show.', 400, ['allowed' => $allowed]); } // A cancelled/denied booking is closed — don't reopen it via a status change. $closed = [BookingStatus::CANCELLED, BookingStatus::DENIED]; if (in_array($booking->status, $closed, true)) { return $this->errorResponse($apiKey, $action, $request, 'BOOKING_NOT_MODIFIABLE', 'This booking cannot change status given its current status.', 409, ['status' => $booking->status]); } // Idempotent: already in the requested status. if ($booking->status === $newStatus) { return $this->successResponse($apiKey, $action, $request, array_merge( $this->serializeBooking($booking), ['message' => 'Booking already has this status.'] ), 200); } $oldStatus = $booking->status; $booking->status = $newStatus; $booking->save(); // Standard status-change event → audit log (recorded as "Automation Bot"). // is_automaton = true so the listener does not require a User. evavel_event(new EventBookingStatusChanged($booking, $oldStatus, $newStatus, null, false, true)); // Optional customer notifications for reservation-lifecycle transitions. // Default is OFF to avoid noise for frequent POS updates (seated/finished). if (!empty($params['send_notifications']) && $params['send_notifications'] === true) { if ($newStatus === BookingStatus::PENDING || $newStatus === BookingStatus::BOOKED) { $this->sendCreationNotifications($booking); } } return $this->successResponse($apiKey, $action, $request, $this->serializeBooking($booking), 200); } //--------------------------------------------------------------------- // Helpers //--------------------------------------------------------------------- protected function validateCreate($params) { $errors = []; if (empty($params['customer_name'])) { $errors['customer_name'] = 'customer_name is required'; } if (empty($params['customer_phone'])) { $errors['customer_phone'] = 'customer_phone is required'; } 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_size']) || intval($params['party_size']) < 1) { $errors['party_size'] = 'party_size is required and must be >= 1'; } return $errors; } /** * Read & validate the optional `table_ids` parameter. * * @return null|int[]|\WP_REST_Response * - null → param not present (leave tables untouched) * - int[] (may be empty) → validated table ids (empty = clear the tables) * - \WP_REST_Response → a 400 error when an id doesn't belong to the restaurant * * Accepts a JSON array ([12,13]) or a comma-separated string ("12,13"). */ protected function resolveTableIds($params, $restaurantId, $apiKey, $action, $request) { if (!isset($params['table_ids'])) { return null; } $raw = $params['table_ids']; if (!is_array($raw)) { $raw = array_filter(array_map('trim', explode(',', (string) $raw)), 'strlen'); } $ids = array_values(array_filter(array_unique(array_map('intval', $raw)), function ($id) { return $id > 0; })); if (empty($ids)) { return []; // explicit clear } $owned = Table::where('restaurant_id', $restaurantId)->whereIn('id', $ids)->get(); $ownedIds = []; foreach ($owned as $table) { $ownedIds[] = intval($table->id); } $invalid = array_values(array_diff($ids, $ownedIds)); if (!empty($invalid)) { return $this->errorResponse($apiKey, $action, $request, 'INVALID_TABLE', 'table_ids contains tables that do not belong to this restaurant.', 400, ['invalid' => $invalid]); } return $ids; } /** * Read & validate the optional `area` parameter (id or name). * * El widget manda siempre el id (area_selected_id), pero un bot recibe * lenguaje natural ("en la terraza"), asi que aqui se acepta tambien el * nombre. El nombre no es unico por definicion: si hay varias areas que se * llaman igual se devuelve error para no elegir una al azar. * * @return null|Area|\WP_REST_Response * - null -> parametro no enviado * - Area -> area validada * - \WP_REST_Response -> error 400/404 */ protected function resolveArea($params, $restaurantId, $apiKey, $action, $request) { if (!isset($params['area']) || $params['area'] === '' || $params['area'] === null) { return null; } $raw = $params['area']; // Por id if (is_numeric($raw)) { $area = Area::where('restaurant_id', $restaurantId)->where('id', intval($raw))->first(); if (!$area) { return $this->errorResponse($apiKey, $action, $request, 'AREA_NOT_FOUND', 'area not found for this restaurant.', 404, ['area' => $raw]); } } // Por nombre else { $name = trim(sanitize_text_field((string) $raw)); $matches = []; foreach (Area::where('restaurant_id', $restaurantId)->get() as $candidate) { if (mb_strtolower(trim($candidate->name)) === mb_strtolower($name)) { $matches[] = $candidate; } } if (count($matches) === 0) { return $this->errorResponse($apiKey, $action, $request, 'AREA_NOT_FOUND', 'area not found for this restaurant.', 404, ['area' => $name]); } if (count($matches) > 1) { $ids = array_map(function ($a) { return intval($a->id); }, $matches); return $this->errorResponse($apiKey, $action, $request, 'AREA_AMBIGUOUS', 'Several areas share this name, use the area id instead.', 400, ['area' => $name, 'matching_ids' => $ids]); } $area = $matches[0]; } // Solo areas abiertas a reservas online, igual que el widget if (!$area->bookable_online) { return $this->errorResponse($apiKey, $action, $request, 'AREA_NOT_BOOKABLE', 'This area is not available for online bookings.', 409, ['area_id' => intval($area->id), 'area_name' => $area->name]); } return $area; } /** * Comprueba que el area elegida es compatible con el servicio y que caben * los comensales. * * Se apoya en los helpers de ShiftCalculations para que la Bot API y el * widget apliquen exactamente la misma regla. * * @return true|\WP_REST_Response */ protected function validateAreaForService($service, $area, $date, $timeSeconds, $party, $apiKey, $action, $request) { $mode = $service->areaSelectionMode(); if ($mode === false) { return $this->errorResponse($apiKey, $action, $request, 'AREA_NOT_SUPPORTED', 'This service does not allow choosing an area.', 400, ['service_id' => intval($service->id), 'service_name' => $service->name]); } // Solo volume_total tiene aforo por area; en los servicios por mesas el // area unicamente restringe de donde se asignan las mesas if ($mode !== 'covers') { return true; } $info = $service->getAreaCoversAvailability($date, $timeSeconds, $party, $area->id); if ($info === null) { return $this->errorResponse($apiKey, $action, $request, 'AREA_NOT_BOOKABLE', 'This area is not available for online bookings.', 409, ['area_id' => intval($area->id), 'area_name' => $area->name]); } if ($party > $info['free']) { return $this->errorResponse($apiKey, $action, $request, 'AREA_FULL', 'Not enough free seats in this area for the requested party size.', 409, [ 'area_id' => intval($area->id), 'area_name' => $area->name, 'max_covers' => $info['max'], 'occupied' => $info['occupied'], 'free_seats' => $info['free'], ]); } return true; } /** * Resolve which Shift/Event to use. * If the caller passes service_id, validate it belongs to the widget. * Otherwise auto-detect by walking the widget's services and matching * the date/time against each service's bookable slots. */ protected function resolveService($restaurant, $widgetId, $serviceIdParam, $date, $timeSeconds) { $widgetServices = $restaurant->getShiftsAndEvents($widgetId); if (!empty($serviceIdParam) && $serviceIdParam !== 'all') { $serviceId = intval($serviceIdParam); foreach ($widgetServices as $svc) { if ($svc->id == $serviceId) { return $svc; } } return null; } // Auto-detect by checking which service has this exact slot bookable foreach ($widgetServices as $svc) { if (!($svc instanceof Shift) && !($svc instanceof Event)) continue; $bookable = $svc->getAllBookableSlots($date); if (is_array($bookable) && in_array($timeSeconds, $bookable)) { return $svc; } } // Fallback: first service from the widget if any return !empty($widgetServices) ? $widgetServices[0] : null; } /** * 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(); } protected function serializeBooking($booking, $extra = []) { $data = [ 'reservation_id' => $booking->uuid, 'booking_id' => intval($booking->id), 'uuid' => $booking->uuid, 'status' => $booking->status, 'restaurant_id' => intval($booking->restaurant_id), 'widget_id' => intval($booking->widget_id), 'service_id' => intval($booking->shift_event_id), 'service_name' => $booking->shift_event_name, 'date' => $booking->date, 'time' => $this->formatTime($booking->time), 'time_seconds' => intval($booking->time), 'party_size' => intval($booking->party), 'duration_minutes' => intval($booking->duration / 60), 'customer_name' => trim($booking->first_name . ' ' . $booking->last_name), 'customer_first_name' => $booking->first_name, 'customer_last_name' => $booking->last_name, 'customer_email' => $booking->email, 'customer_phone' => $booking->phone, 'customer_dial_code' => $booking->dial_code, 'notes' => $booking->notes, 'source' => $booking->source, 'language' => $booking->language, 'created_at' => $booking->date_created, // Assigned table(s), if any. Lets the caller (e.g. a host/table app) // see which tables are already taken so it does not hand out a table // that is pending/reserved for a guest. Empty array when none. 'tables' => $this->serializeTables($booking), // Area elegida por el cliente (la que guarda el widget en // area_selected_id). null cuando el servicio no permite elegir area // o no se pidio ninguna. Es distinto del area de las mesas // asignadas, que va dentro de cada entrada de `tables`. 'area' => $this->serializeArea($booking), ]; if (!empty($extra)) { $data = array_merge($data, $extra); } return $data; } /** * Area elegida en la reserva (area_selected_id), o null. * * @param Booking $booking * @return array|null */ protected function serializeArea($booking) { if (!$booking->area_selected_id) { return null; } return [ 'id' => intval($booking->area_selected_id), 'name' => $booking->areaSelectedName, ]; } /** * Serialize the table(s) assigned to a booking (with their area). * Returns an empty array when no table is assigned. * * @param Booking $booking * @return array */ protected function serializeTables($booking) { $tables = []; foreach ($booking->tables as $table) { $tables[] = [ 'id' => intval($table->id), 'name' => $table->name, 'area_id' => $table->area_id ? intval($table->area_id) : null, 'area_name' => $table->area ? $table->area->name : null, ]; } return $tables; } protected function sendCreationNotifications($booking) { $lang = $booking->language ?: 'en'; if ($booking->status === BookingStatus::PENDING) { $booking->sendEmailPending($lang); $booking->sendEmailAdminPending(); if ($booking->agree_receive_sms == 1) { $booking->sendSmsPending($lang); } $booking->sendSmsPendingAdmin(null); $booking->sendWhatsappPending($lang); } else if ($booking->status === BookingStatus::BOOKED) { $booking->sendEmailBooked($lang); $booking->sendEmailAdminBooked(); if ($booking->agree_receive_sms == 1) { $booking->sendSmsBooked($lang); } $booking->sendSmsBookedAdmin(null); $booking->sendWhatsappBooked($lang); } else if ($booking->status === BookingStatus::CONFIRMED) { $booking->sendEmailConfirmed($lang); $booking->sendEmailAdminConfirmed(); $booking->sendWhatsappConfirmed($lang); } if ($booking->restaurant) { $booking->restaurant->notify(new \Alexr\Notifications\BookingOnlineReceived($booking)); } } protected function sendModificationNotifications($booking) { $lang = $booking->language ?: 'en'; $booking->sendEmailModified($lang); $booking->sendEmailAdminModified(); if ($booking->restaurant) { $booking->restaurant->notify(new \Alexr\Notifications\BookingModifiedReceived($booking, [])); } } protected function sendCancellationNotifications($booking) { $lang = $booking->language ?: 'en'; $booking->sendEmailCancelled($lang); $booking->sendEmailAdminCancelled(); $booking->sendWhatsappCancelled($lang); if ($booking->restaurant) { $booking->restaurant->notify(new \Alexr\Notifications\BookingCancelledByUser($booking)); } } }