tenantId(); $filter = $request->filter ?? 'all'; $limit = intval($request->limit ?? 50); $limit = min($limit, 100); // Build query: conversations ordered by last message $query = WhatsappConversation::where('restaurant_id', $tenantId) ->orderBy('last_message_at', 'DESC'); if ($filter === 'unread') { $query->where('unread_count', '>', 0); // The unread set is bounded; return ALL unread conversations (not just // the most recent N) so every conversation counted by the unread badge // is reachable and can be marked as read. 500 is an effectively // unlimited safety cap. $limit = 500; } $conversations = $query->limit($limit)->get(); $totalUnread = 0; $items = []; // Pre-load restaurant for timezone $restaurant = Restaurant::find($tenantId); $timezone = $restaurant->timezone ?? 'UTC'; $dateNow = evavel_now_timezone_formatted($timezone, 'Y-m-d'); foreach ($conversations as $conv) { $totalUnread += (int) $conv->unread_count; // Load active booking data if linked $activeBookingData = null; if ($conv->active_booking_id) { $booking = Booking::find($conv->active_booking_id); if ($booking) { $activeBookingData = [ 'id' => $booking->id, 'token' => $booking->token ?? null, 'first_name' => $booking->first_name, 'last_name' => $booking->last_name, 'date' => $booking->date, 'time' => $booking->time, 'party' => $booking->party, 'status' => $booking->status, 'tablesList' => $booking->tablesList ?? null, ]; } } // Get last message snippet $lastMessage = WhatsappMessage::where('conversation_id', $conv->id) //->orderBy('date_created', 'DESC') ->orderBy('id', 'DESC') ->limit(1) ->first(); $snippet = null; $snippetDirection = null; if ($lastMessage) { $body = $lastMessage->body ?? ''; $snippet = mb_strlen($body) > 80 ? mb_substr($body, 0, 80) . '…' : $body; $snippetDirection = $lastMessage->direction; } // Find the next future booking for this phone (most relevant) $nextBooking = null; if ($conv->phone_normalized && !WhatsappConversation::isBsuid($conv->phone_normalized)) { $futureBooking = $this->findNextBookingByPhone($tenantId, $conv->phone_normalized, $dateNow); if ($futureBooking && (!$activeBookingData || $futureBooking->id !== $activeBookingData['id'])) { $nextBooking = [ 'id' => $futureBooking->id, 'date' => $futureBooking->date, 'time' => $futureBooking->time, 'party' => $futureBooking->party, 'status' => $futureBooking->status, ]; } } $customer = Customer::find($conv->customer_id); $has_whatsapp = true; if ($customer) { $has_whatsapp = $customer->has_whatsapp; } $items[] = [ 'conversation_id' => $conv->id, 'phone' => $conv->phone, 'phone_normalized' => $conv->phone_normalized, 'profile_name' => $conv->profile_name, 'customer_id' => $conv->customer_id, 'active_booking_id' => $conv->active_booking_id, 'bsuid' => $conv->bsuid, 'status' => $conv->status, 'customer_status' => $conv->customer_status ?? WhatsappConversation::CUSTOMER_STATUS_IDLE, 'unread_count' => (int) $conv->unread_count, 'message_count' => (int) $conv->message_count, 'last_message_at' => $conv->last_message_at, 'window_open' => $conv->isWindowOpen, 'window_expires_at' => $conv->window_expires_at, 'snippet' => $snippet, 'snippet_direction' => $snippetDirection, // Active booking linked to this conversation 'active_booking' => $activeBookingData, // Next future booking for this customer (if different) 'next_booking' => $nextBooking, // Display name: active booking name > customer name > profile_name > phone 'display_name' => $this->resolveDisplayName($conv, $activeBookingData), // Customer has_whatsapp flag 'customer_has_whatsapp' => $has_whatsapp ]; } // Authoritative unread total across ALL conversations (matches the // top-bar badge endpoint), independent of the list filter/limit, so the // "Unread" toggle count never disagrees with the badge. $totalUnread = (int) WhatsappConversation::where('restaurant_id', $tenantId) ->where('unread_count', '>', 0) ->get() ->sum('unread_count'); return $this->response([ 'success' => true, 'data' => [ 'conversations' => $items, 'total_unread' => $totalUnread, ] ]); } // ===================================================================== // TAB 2: BOOKINGS - Bookings filtered by date, with conversation data // ===================================================================== /** * Get bookings list with WhatsApp conversation data for the Bookings tab. * * @param Request $request * @return \WP_REST_Response */ public function getInboxBookings(Request $request) { $tenantId = $request->tenantId(); $dateRange = $request->date_range ?? 'today'; $status = $request->status ?? 'all'; $onlyUnread = $request->only_unread ?? false; // Get restaurant timezone $restaurant = Restaurant::find($tenantId); $timezone = $restaurant->timezone ?? 'UTC'; // Build the base query $query = Booking::where('restaurant_id', $tenantId) ->where('status', '!=', BookingStatus::SELECTED) ->where('status', '!=', BookingStatus::DELETED); // Apply date range filter using restaurant timezone $dateNow = evavel_now_timezone_formatted($timezone, 'Y-m-d'); switch ($dateRange) { case 'today': $query->where('date', $dateNow); break; case 'tomorrow': $dateTomorrow = evavel_now_timezone($timezone)->modify('+1 day')->format('Y-m-d'); $query->where('date', $dateTomorrow); break; case 'week': $dateEndWeek = evavel_now_timezone($timezone)->modify('+7 days')->format('Y-m-d'); $query->where('date', '>=', $dateNow) ->where('date', '<=', $dateEndWeek); break; case 'past': $query->where('date', '<', $dateNow); $query->orderBy('date', 'DESC'); break; case 'all': default: $datePast = evavel_now_timezone($timezone)->modify('-7 days')->format('Y-m-d'); $query->where('date', '>=', $datePast); break; } // Apply status filter if ($status !== 'all') { $query->where('status', $status); } // Order by date and time if ($dateRange !== 'past') { $query->orderBy('date', 'ASC'); } $bookings = $query->get(); // Fetch conversations for these bookings by matching phone numbers. // In the customer-centric model, conversations are keyed by phone, not booking_id. // We build a phone→conversation map to avoid duplicate queries. $conversationsByPhone = []; foreach ($bookings as $booking) { $bookingPhone = WhatsappConversation::normalizePhone( ($booking->dial_code ?? '') . ($booking->phone ?? '') ); if (!empty($bookingPhone) && !isset($conversationsByPhone[$bookingPhone])) { $conv = WhatsappConversation::where('restaurant_id', $tenantId) ->where('phone_normalized', $bookingPhone) ->first(); $conversationsByPhone[$bookingPhone] = $conv; } } // Build response data $bookingsData = []; $totalUnread = 0; foreach ($bookings as $booking) { $bookingPhone = WhatsappConversation::normalizePhone( ($booking->dial_code ?? '') . ($booking->phone ?? '') ); $conv = $conversationsByPhone[$bookingPhone] ?? null; $unreadCount = $conv ? (int) $conv->unread_count : 0; $totalUnread += $unreadCount; $bookingItem = [ 'id' => $booking->id, 'token' => $booking->token ?? null, 'first_name' => $booking->first_name, 'last_name' => $booking->last_name, 'name' => ($booking->first_name ?? '') . ' ' . ($booking->last_name ?? ''), 'phone' => $booking->phone, 'dial_code' => $booking->dial_code, 'email' => $booking->email, 'date' => $booking->date, 'time' => $booking->time, 'party' => $booking->party, 'status' => $booking->status, 'tablesList' => $booking->tablesList ?? null, // Conversation data (customer-centric: matched by phone) 'conversation_id' => $conv ? $conv->id : null, 'has_conversation' => $conv !== null, 'unread_count' => $unreadCount, 'last_message_at' => $conv ? $conv->last_message_at : null, 'window_open' => $conv ? $conv->isWindowOpen : false, 'window_expires_at' => $conv ? $conv->window_expires_at : null, 'message_count' => $conv ? (int) $conv->message_count : 0, 'customer_status' => $conv ? ($conv->customer_status ?? WhatsappConversation::CUSTOMER_STATUS_IDLE) : null, ]; $bookingsData[] = $bookingItem; } // If only_unread filter is active, filter on PHP side if ($onlyUnread) { $bookingsData = array_values(array_filter($bookingsData, function ($b) { return $b['unread_count'] > 0; })); } // Sort: unread first, then by date/time usort($bookingsData, function ($a, $b) { if ($a['unread_count'] > 0 && $b['unread_count'] === 0) return -1; if ($b['unread_count'] > 0 && $a['unread_count'] === 0) return 1; $dateCompare = strcmp($a['date'], $b['date']); if ($dateCompare !== 0) return $dateCompare; return ($a['time'] ?? 0) - ($b['time'] ?? 0); }); return $this->response([ 'success' => true, 'data' => [ 'bookings' => $bookingsData, 'total_unread' => $totalUnread, ] ]); } // ===================================================================== // BOOKING → CONVERSATION resolver (for Bookings tab in V2) // ===================================================================== /** * Resolve a booking to its conversation. * Finds the conversation by the booking's customer phone, * sets active_booking_id, and returns the conversation data. * * @param Request $request * @return \WP_REST_Response */ public function resolveBookingConversation(Request $request) { $tenantId = $request->tenantId(); $bookingId = $request->bookingId; $booking = Booking::where('restaurant_id', $tenantId)->find($bookingId); if (!$booking) { return $this->response([ 'success' => false, 'error' => __eva('Booking not found.') ]); } // Find conversation by booking's phone $bookingPhone = WhatsappConversation::normalizePhone( ($booking->dial_code ?? '') . ($booking->phone ?? '') ); $conversation = WhatsappConversation::where('restaurant_id', $tenantId) ->where('phone_normalized', $bookingPhone) ->first(); if (!$conversation) { return $this->response([ 'success' => true, 'data' => [ 'conversation' => null, 'message' => __eva('No conversation found for this booking.') ] ]); } // Set active_booking_id so messages are linked to this booking if ($conversation->getActiveBookingId() !== (int) $bookingId) { $conversation->setActiveBooking($bookingId); } return $this->response([ 'success' => true, 'data' => [ 'conversation' => $conversation->toArray(), ] ]); } // ===================================================================== // CONVERSATION DETAIL - Load by conversation_id (for both tabs) // ===================================================================== /** * Get conversation details, messages and templates by conversation_id. * This is the unified endpoint used by both Inbox and Bookings tabs. * * @param Request $request * @return \WP_REST_Response */ public function getConversationDetail(Request $request) { $tenantId = $request->tenantId(); $conversationId = $request->conversationId; $conversation = WhatsappConversation::where('restaurant_id', $tenantId) ->where('id', $conversationId) ->first(); if (!$conversation) { return $this->response([ 'success' => false, 'error' => __eva('Conversation not found.') ]); } // Load active booking if linked $booking = null; $bookingData = null; if ($conversation->active_booking_id) { $booking = Booking::find($conversation->active_booking_id); if ($booking) { $bookingData = $booking->toArray(); } } // Load customer if linked $customerData = null; if ($conversation->customer_id) { $customer = \Alexr\Models\Customer::find($conversation->customer_id); if ($customer) { $customerData = [ 'id' => $customer->id, 'first_name' => $customer->first_name, 'last_name' => $customer->last_name, 'name' => trim(($customer->first_name ?? '') . ' ' . ($customer->last_name ?? '')), 'email' => $customer->email, 'phone' => $customer->phone, 'dial_code' => $customer->dial_code, 'has_whatsapp' => $customer->has_whatsapp, ]; } } // Get messages $messages = WhatsappMessage::getForConversation($conversation->id, 100, 0); $messages = array_map(function($message) { return $message->toArray(); }, $messages->toArray()); $messages = array_reverse($messages); // Get templates: booking-dependent types need a booking, but custom_messages work without one $templatesMapped = ['messages' => [], 'reminders' => [], 'reconfirmations' => [], 'custom_messages' => []]; if ($booking) { $lang = $booking->language ?? 'en'; $templatesMapped = $this->getTemplatesMapped($tenantId, $lang); } else { // Even without a booking, load custom_messages (they don't depend on booking data) $restaurant = \Alexr\Models\Restaurant::find($tenantId); $lang = $restaurant ? ($restaurant->language ?? 'en') : 'en'; $fullMapped = $this->getTemplatesMapped($tenantId, $lang); $templatesMapped['custom_messages'] = $fullMapped['custom_messages'] ?? []; } // Get available templates for this tenant (full template data for bubble rendering) $settingId = \Alexr\Settings\WhatsappTemplate::getId($tenantId); $setting = \Alexr\Settings\WhatsappTemplate::find($settingId); $templates = $setting ? ($setting->templates ?? []) : []; return $this->response([ 'success' => true, 'data' => [ 'conversation' => $conversation->toArray(), 'booking' => $bookingData, 'customer' => $customerData, 'messages' => $messages, 'templates' => $templates, 'templates_mapped' => $templatesMapped, 'window_open' => $conversation->isWindowOpen, 'window_expires_at' => $conversation->window_expires_at, 'customer_has_whatsapp' => $customerData ? ($customerData['has_whatsapp'] ?? true) : true, ] ]); } /** * Get updated messages for polling by conversation_id * * @param Request $request * @return \WP_REST_Response */ public function getConversationMessages(Request $request) { $tenantId = $request->tenantId(); $conversationId = $request->conversationId; $lastMessageId = $request->last_message_id ?? 0; $conversation = WhatsappConversation::where('restaurant_id', $tenantId) ->where('id', $conversationId) ->first(); if (!$conversation) { return $this->response([ 'success' => true, 'data' => [ 'messages' => [], 'conversation' => null, 'has_new_messages' => false ] ]); } $messages = WhatsappMessage::where('conversation_id', $conversation->id); if ($lastMessageId > 0) { $messages = $messages->where('id', '>', $lastMessageId); } $messages = $messages->orderBy('id', 'DESC') ->limit(50) ->get(); $messagesArray = array_map(function($message) { return $message->toArray(); }, $messages->toArray()); $messagesArray = array_reverse($messagesArray); return $this->response([ 'success' => true, 'data' => [ 'messages' => $messagesArray, 'conversation' => $conversation->toArray(), 'has_new_messages' => !empty($messagesArray), 'window_open' => $conversation->isWindowOpen, 'window_expires_at' => $conversation->window_expires_at, ] ]); } /** * Send a free text message by conversation_id * * @param Request $request * @return \WP_REST_Response */ public function sendConversationMessage(Request $request) { $tenantId = $request->tenantId(); $conversationId = $request->conversationId; $messageText = trim($request->message ?? ''); if (empty($messageText)) { return $this->response([ 'success' => false, 'error' => __eva('Message cannot be empty.') ]); } $conversation = WhatsappConversation::where('restaurant_id', $tenantId) ->where('id', $conversationId) ->first(); if (!$conversation) { return $this->response([ 'success' => false, 'error' => __eva('Conversation not found.') ]); } $user = Eva::make('user'); $senderInfo = [ 'sender_type' => 'agent', 'sender_user_id' => $user ? $user->id : null, 'sender_user_name' => $user ? $user->name : null, ]; // If conversation has an active booking, use the booking method if ($conversation->active_booking_id) { $booking = Booking::find($conversation->active_booking_id); if ($booking) { $result = $booking->sendWhatsappMessage($messageText, $senderInfo); if (!$result['success']) { return $this->response([ 'success' => false, 'error' => $result['message'] ]); } return $this->response([ 'success' => true, 'message' => __eva('Message sent successfully.'), 'data' => $result['data'] ]); } } // No booking: send directly via WhatsappManager using conversation phone $phone = $conversation->phone; if (empty($phone)) { return $this->response([ 'success' => false, 'error' => __eva('No phone number available for this conversation.') ]); } $manager = \Alexr\Whatsapp\WhatsappManager::forRestaurant($tenantId); $result = $manager->sendMessage( $phone, $messageText, null, // no booking $conversation->customer_id, null, $senderInfo ); if (!$result['success']) { return $this->response([ 'success' => false, 'error' => $result['message'] ]); } return $this->response([ 'success' => true, 'message' => __eva('Message sent successfully.'), 'data' => $result['data'] ]); } /** * Send a template by conversation_id * * @param Request $request * @return \WP_REST_Response */ public function sendConversationTemplate(Request $request) { //ray('Send template by conversation_id', $request); $tenantId = $request->tenantId(); $conversationId = $request->conversationId; $type = $request->type; $templateSid = $request->template_sid; $user = Eva::make('user'); $senderInfo = [ 'sender_type' => 'agent', 'sender_user_id' => $user ? $user->id : null, 'sender_user_name' => $user ? $user->name : null, ]; if (empty($type) || empty($templateSid)) { return $this->response([ 'success' => false, 'error' => __eva('Template type and SID are required.') ]); } $conversation = WhatsappConversation::where('restaurant_id', $tenantId) ->where('id', $conversationId) ->first(); if (!$conversation) { return $this->response([ 'success' => false, 'error' => __eva('Conversation not found.') ]); } // For custom_message type, booking is optional — can send directly to the conversation phone $booking = null; if ($conversation->active_booking_id) { $booking = Booking::find($conversation->active_booking_id); } // Booking-dependent types require a booking if (!$booking && !in_array($type, ['custom_message'])) { return $this->response([ 'success' => false, 'error' => __eva('This template type requires a linked booking.') ]); } $lang = $booking ? ($booking->language ?? 'en') : 'en'; $result = false; switch ($type) { case 'message': $messageKey = $request->message_key; if (empty($messageKey)) { return $this->response(['success' => false, 'error' => __eva('Message key is required.')]); } $result = $booking->sendWhatsappTemplate($messageKey, $lang, $senderInfo); break; case 'reminder': $reminderId = $request->reminder_id; if (empty($reminderId)) { return $this->response(['success' => false, 'error' => __eva('Reminder ID is required.')]); } $result = $booking->sendWhatsappReminder($reminderId, $lang, $senderInfo); break; case 'reconfirmation': $reconfirmationId = $request->reconfirmation_id; if (empty($reconfirmationId)) { return $this->response(['success' => false, 'error' => __eva('Reconfirmation ID is required.')]); } $result = $booking->sendWhatsappReconfirmation($reconfirmationId, $lang, $senderInfo); break; case 'custom_message': $customMessageId = $request->custom_message_id; if (empty($customMessageId)) { return $this->response(['success' => false, 'error' => __eva('Custom message ID is required.')]); } if ($booking) { $result = $booking->sendWhatsappCustomMessage($customMessageId, $lang, $senderInfo); } else { // Send directly to the conversation phone (no booking context) $result = $this->sendCustomMessageToPhone($conversation, $tenantId, $customMessageId, $lang, $senderInfo); } break; default: return $this->response(['success' => false, 'error' => __eva('Invalid template type.')]); } if ($result === false || $result === null) { return $this->response([ 'success' => false, 'error' => __eva('Failed to send template. Check configuration.') ]); } if (is_array($result) && !$result['success']) { return $this->response([ 'success' => false, 'error' => $result['message'] ?? __eva('Failed to send template.') ]); } return $this->response([ 'success' => true, 'message' => __eva('Template sent successfully.'), 'data' => is_array($result) ? ($result['data'] ?? []) : [] ]); } /** * Send a custom message template directly to a phone (no booking required). * Used when there is a conversation but no linked booking. */ protected function sendCustomMessageToPhone($conversation, $tenantId, $customMessageId, $lang, $senderInfo) { $customMessage = \Alexr\Settings\WhatsappCustomMessage::where('restaurant_id', $tenantId) ->where('id', $customMessageId) ->first(); if (!$customMessage || !$customMessage->isEnabled()) { return ['success' => false, 'message' => __eva('Custom message not found or not active.')]; } $templateConfig = $customMessage->getTemplateConfig($lang); if (!$templateConfig || empty($templateConfig['template_sid'])) { return ['success' => false, 'message' => __eva('No template configured for this language.')]; } $templateSid = $templateConfig['template_sid']; $templateName = $templateConfig['template_name'] ?? ''; $fieldMapping = $templateConfig['field_mapping'] ?? []; $staticValues = $templateConfig['static_values'] ?? []; // Resolve variables: only restaurant-level fields and static values (no booking fields) $restaurant = \Alexr\Models\Restaurant::find($tenantId); if (!$restaurant) { return ['success' => false, 'message' => __eva('Restaurant not found.')]; } // Resolve variables: restaurant-level fields + custom_link static values $variables = \Alexr\Whatsapp\RestaurantVariableResolver::resolveVariables( $fieldMapping, $restaurant, null, [], null, $staticValues ); // Get phone from conversation $phone = $conversation->phone_normalized; if (empty($phone)) { return ['success' => false, 'message' => __eva('No phone number for this conversation.')]; } $manager = new \Alexr\Whatsapp\WhatsappManager($tenantId); $result = $manager->sendTemplate( $phone, $templateSid, $variables, null, // no booking_id $conversation->customer_id, $templateName, $senderInfo ); return $result; } /** * Mark conversation as read by conversation_id * * @param Request $request * @return \WP_REST_Response */ public function markConversationAsRead(Request $request) { $tenantId = $request->tenantId(); $conversationId = $request->conversationId; $conversation = WhatsappConversation::where('restaurant_id', $tenantId) ->where('id', $conversationId) ->first(); if (!$conversation) { return $this->response([ 'success' => true, 'message' => __eva('No conversation found.') ]); } $unreadMessages = WhatsappMessage::where('conversation_id', $conversation->id) ->where('direction', WhatsappMessage::DIRECTION_INBOUND) ->whereIsNull('read_at') ->get(); // Mark conversation as read locally $conversation->markAsRead(); foreach ($unreadMessages as $message) { $message->read_at = evavel_now(); $message->save(); } // Send read receipt to WhatsApp via provider (only for the last message) $this->sendReadReceiptToProvider($tenantId, $unreadMessages); return $this->response([ 'success' => true, 'message' => __eva('Conversation marked as read.') ]); } /** * Send read receipt to the WhatsApp provider for the last unread message. * Marking the last message as read in WhatsApp automatically marks all previous ones. * * @param int $tenantId * @param mixed $unreadMessages Collection of unread messages * @return void */ protected function sendReadReceiptToProvider(int $tenantId, $unreadMessages): void { if (empty($unreadMessages) || count($unreadMessages) === 0) { return; } // Get the last unread message (highest ID) $messagesArray = is_array($unreadMessages) ? $unreadMessages : $unreadMessages->toArray(); $lastMessage = null; foreach ($messagesArray as $message) { if (!$lastMessage || $message->id > $lastMessage->id) { $lastMessage = $message; } } if (!$lastMessage || empty($lastMessage->message_sid)) { return; } try { $provider = WhatsappProviderFactory::forRestaurant($tenantId); if ($provider) { $provider->markAsRead($lastMessage->message_sid); } } catch (\Exception $e) { error_log('[WhatsApp ReadReceipt] Failed to send read receipt: ' . $e->getMessage()); } } // ===================================================================== // UNREAD COUNT (lightweight endpoint for badge polling) // ===================================================================== /** * Get total unread count across all bookings * * @param Request $request * @return \WP_REST_Response */ public function getInboxUnreadCount(Request $request) { $tenantId = $request->tenantId(); $totalUnread = 0; $unreadConversations = WhatsappConversation::where('restaurant_id', $tenantId) ->where('unread_count', '>', 0) ->get(); foreach ($unreadConversations as $conv) { $totalUnread += (int) $conv->unread_count; } return $this->response([ 'success' => true, 'data' => [ 'total_unread' => $totalUnread, ] ]); } // ===================================================================== // CUSTOMER STATUS & ACTIVE BOOKING MANAGEMENT // ===================================================================== /** * Set customer_status on a conversation. * Used by the frontend to manually control the interaction state * (e.g., agent takes over → HUMAN_INTERACTION, agent releases → IDLE). * * @param Request $request * @return \WP_REST_Response */ public function setConversationCustomerStatus(Request $request) { $tenantId = $request->tenantId(); $conversationId = $request->conversationId; $customerStatus = $request->customer_status; $validStatuses = [ WhatsappConversation::CUSTOMER_STATUS_IDLE, WhatsappConversation::CUSTOMER_STATUS_AWAITING_REPLY, WhatsappConversation::CUSTOMER_STATUS_HUMAN_INTERACTION, WhatsappConversation::CUSTOMER_STATUS_BOT_INTERACTION, ]; if (!in_array($customerStatus, $validStatuses)) { return $this->response([ 'success' => false, 'error' => __eva('Invalid customer status.') ]); } $conversation = WhatsappConversation::where('restaurant_id', $tenantId) ->where('id', $conversationId) ->first(); if (!$conversation) { return $this->response([ 'success' => false, 'error' => __eva('Conversation not found.') ]); } $conversation->setCustomerStatus($customerStatus); return $this->response([ 'success' => true, 'message' => __eva('Customer status updated.'), 'data' => [ 'customer_status' => $conversation->customer_status, ] ]); } /** * Set or clear the active_booking_id on a conversation. * Used by the frontend to manually switch which booking is active * for this customer's conversation. * * @param Request $request * @return \WP_REST_Response */ public function setConversationActiveBooking(Request $request) { $tenantId = $request->tenantId(); $conversationId = $request->conversationId; $bookingId = $request->booking_id; // can be null to clear $conversation = WhatsappConversation::where('restaurant_id', $tenantId) ->where('id', $conversationId) ->first(); if (!$conversation) { return $this->response([ 'success' => false, 'error' => __eva('Conversation not found.') ]); } if ($bookingId) { // Verify booking exists and belongs to this restaurant $booking = Booking::where('restaurant_id', $tenantId)->find($bookingId); if (!$booking) { return $this->response([ 'success' => false, 'error' => __eva('Booking not found.') ]); } $conversation->setActiveBooking($bookingId); } else { $conversation->clearActiveBooking(); } return $this->response([ 'success' => true, 'message' => __eva('Active booking updated.'), 'data' => [ 'active_booking_id' => $conversation->active_booking_id, ] ]); } /** * Get all bookings for a customer (by conversation phone). * Used by the frontend to show a list of bookings for the active conversation. * * @param Request $request * @return \WP_REST_Response */ public function getConversationBookings(Request $request) { $tenantId = $request->tenantId(); $conversationId = $request->conversationId; $conversation = WhatsappConversation::where('restaurant_id', $tenantId) ->where('id', $conversationId) ->first(); if (!$conversation) { return $this->response([ 'success' => false, 'error' => __eva('Conversation not found.') ]); } // Find bookings by matching phone number $bookings = []; if ($conversation->phone_normalized && !WhatsappConversation::isBsuid($conversation->phone_normalized)) { $phoneDigits = preg_replace('/[^0-9]/', '', $conversation->phone_normalized); $allBookings = Booking::where('restaurant_id', $tenantId) ->where('status', '!=', BookingStatus::DELETED) ->orderBy('date', 'DESC') ->limit(20) ->get(); foreach ($allBookings as $booking) { $bookingPhone = preg_replace('/[^0-9]/', '', ($booking->dial_code ?? '') . ($booking->phone ?? '')); if (!empty($bookingPhone) && str_contains($phoneDigits, $bookingPhone)) { $bookings[] = [ 'id' => $booking->id, 'first_name' => $booking->first_name, 'last_name' => $booking->last_name, 'date' => $booking->date, 'time' => $booking->time, 'party' => $booking->party, 'status' => $booking->status, 'tablesList' => $booking->tablesList ?? null, ]; } } } // Also check by customer_id if available if (empty($bookings) && $conversation->customer_id) { $customerBookings = Booking::where('restaurant_id', $tenantId) ->where('customer_id', $conversation->customer_id) ->where('status', '!=', BookingStatus::DELETED) ->orderBy('date', 'DESC') ->limit(20) ->get(); foreach ($customerBookings as $booking) { $bookings[] = [ 'id' => $booking->id, 'first_name' => $booking->first_name, 'last_name' => $booking->last_name, 'date' => $booking->date, 'time' => $booking->time, 'party' => $booking->party, 'status' => $booking->status, 'tablesList' => $booking->tablesList ?? null, ]; } } return $this->response([ 'success' => true, 'data' => [ 'bookings' => $bookings, 'active_booking_id' => $conversation->active_booking_id, ] ]); } /** * Resolve conversation for a customer (find or create by phone) */ public function resolveCustomerConversation(Request $request) { $tenantId = $request->tenantId(); $customerId = $request->customerId; $customer = Customer::where('restaurant_id', $tenantId) ->where('id', $customerId) ->first(); if (!$customer) { return $this->response([ 'success' => false, 'error' => __eva('Customer not found.') ]); } if (empty($customer->phone)) { return $this->response([ 'success' => false, 'error' => __eva('Customer has no phone number.') ]); } // First try to find existing conversation linked to this customer $conversation = WhatsappConversation::where('restaurant_id', $tenantId) ->where('customer_id', $customerId) ->orderBy('last_message_at', 'DESC') ->first(); // If not found by customer_id, try by phone number if (!$conversation) { $fullPhone = '+' . ($customer->dial_code ?? '') . $customer->phone; $conversation = WhatsappConversation::findOrCreateByPhone($tenantId, $fullPhone, [ 'customer_id' => $customerId, ]); } return $this->response([ 'success' => true, 'data' => [ 'conversation_id' => $conversation->id, ] ]); } // ===================================================================== // HELPERS // ===================================================================== /** * Find the next future booking for a given phone number */ protected function findNextBookingByPhone($tenantId, $phoneNormalized, $dateNow) { // Extract digits from normalized phone for matching $phoneDigits = preg_replace('/[^0-9]/', '', $phoneNormalized); // Try to find future bookings matching this phone $bookings = Booking::where('restaurant_id', $tenantId) ->where('date', '>=', $dateNow) ->where('status', '!=', BookingStatus::CANCELLED) ->where('status', '!=', BookingStatus::DELETED) ->where('status', '!=', BookingStatus::NO_SHOW) ->orderBy('date', 'ASC') ->limit(10) ->get(); foreach ($bookings as $booking) { $bookingPhone = preg_replace('/[^0-9]/', '', ($booking->dial_code ?? '') . ($booking->phone ?? '')); if (!empty($bookingPhone) && str_contains($phoneDigits, $bookingPhone)) { return $booking; } } return null; } /** * Resolve the display name for a conversation */ protected function resolveDisplayName($conversation, $bookingData) { // 1. If booking has name, use it if ($bookingData) { $name = trim(($bookingData['first_name'] ?? '') . ' ' . ($bookingData['last_name'] ?? '')); if (!empty($name)) { return $name; } } // 2. If customer linked, use customer name if ($conversation->customer_id) { $customer = \Alexr\Models\Customer::find($conversation->customer_id); if ($customer) { $name = trim(($customer->first_name ?? '') . ' ' . ($customer->last_name ?? '')); if (!empty($name)) { return $name; } } } // 3. Profile name from WhatsApp if (!empty($conversation->profile_name)) { return $conversation->profile_name; } // 4. Fallback to phone return $conversation->phone ?? 'Unknown'; } }