'int', 'restaurant_id' => 'int', 'customer_id' => 'int', 'active_booking_id' => 'int', 'unread_count' => 'int', 'message_count' => 'int', ]; protected $fillable = [ 'restaurant_id', 'customer_id', 'active_booking_id', 'phone', 'phone_normalized', 'profile_name', 'wa_id', 'bsuid', 'status', 'customer_status', 'window_expires_at', 'last_message_at', 'last_inbound_at', 'last_outbound_at', 'unread_count', 'message_count', ]; protected $appends = [ 'is_window_open', ]; const STATUS_OPEN = 'open'; const STATUS_CLOSED = 'closed'; const WINDOW_DURATION_HOURS = 24; // Customer status constants const CUSTOMER_STATUS_IDLE = 'IDLE'; const CUSTOMER_STATUS_AWAITING_REPLY = 'AWAITING_REPLY'; const CUSTOMER_STATUS_HUMAN_INTERACTION = 'HUMAN_INTERACTION'; const CUSTOMER_STATUS_BOT_INTERACTION = 'BOT_INTERACTION'; // ------------------------------------------------------------------------- // Relationships // ------------------------------------------------------------------------- public function restaurant() { return $this->belongsTo(Restaurant::class); } public function customer() { return $this->belongsTo(Customer::class); } public function activeBooking() { return $this->belongsTo(Booking::class, 'active_booking_id'); } public function messages() { return $this->hasMany(WhatsappMessage::class, 'conversation_id'); } // ------------------------------------------------------------------------- // Attributes // ------------------------------------------------------------------------- /** * Check if the 24h messaging window is still open */ public function getIsWindowOpenAttribute(): bool { if (!$this->window_expires_at) { return false; } return strtotime($this->window_expires_at) > time(); } /** * Get time remaining in window (in seconds) */ public function getWindowRemainingSecondsAttribute(): int { if (!$this->isWindowOpen) { return 0; } return max(0, strtotime($this->window_expires_at) - time()); } // ------------------------------------------------------------------------- // Static Methods - Find or Create // ------------------------------------------------------------------------- /** * Find or create conversation by phone number (primary method). * One conversation per customer per restaurant (keyed by phone_normalized). */ public static function findOrCreateByPhone(int $restaurantId, string $phone, array $attributes = []): self { $phoneNormalized = self::normalizePhone($phone); $conversation = self::where('restaurant_id', $restaurantId) ->where('phone_normalized', $phoneNormalized) ->first(); if (!$conversation) { $conversation = self::create(array_merge([ 'restaurant_id' => $restaurantId, 'phone' => $phone, 'phone_normalized' => $phoneNormalized, 'status' => self::STATUS_OPEN, 'customer_status' => self::CUSTOMER_STATUS_IDLE, ], $attributes)); } return $conversation; } /** * Find or create conversation by BSUID (for users who hide their phone number). */ public static function findOrCreateByBsuid(int $restaurantId, string $bsuid, array $attributes = []): self { $conversation = self::where('restaurant_id', $restaurantId) ->where('bsuid', $bsuid) ->first(); if (!$conversation) { $conversation = self::create(array_merge([ 'restaurant_id' => $restaurantId, 'phone' => $bsuid, 'phone_normalized' => $bsuid, 'bsuid' => $bsuid, 'status' => self::STATUS_OPEN, 'customer_status' => self::CUSTOMER_STATUS_IDLE, ], $attributes)); } return $conversation; } /** * Find or create conversation by identifier (phone or BSUID). * This is the main entry point for resolving conversations. * * - If phone is available: use phone as primary key (and store bsuid if provided) * - If no phone but bsuid: use bsuid as primary key */ public static function findOrCreateByIdentifier(int $restaurantId, ?string $phone, ?string $bsuid = null, array $attributes = []): self { // Case 1: We have a real phone number if ($phone && !self::isBsuid($phone)) { $conversation = self::findOrCreateByPhone($restaurantId, $phone, $attributes); // Link BSUID to existing conversation if we now have one if ($bsuid && !$conversation->bsuid) { $conversation->bsuid = $bsuid; $conversation->save(); } return $conversation; } // Case 2: No real phone, but we have BSUID if ($bsuid) { return self::findOrCreateByBsuid($restaurantId, $bsuid, $attributes); } // Case 3: Phone is actually a BSUID (from field contains BSUID format) if ($phone && self::isBsuid($phone)) { return self::findOrCreateByBsuid($restaurantId, $phone, $attributes); } // Fallback: use whatever phone we have return self::findOrCreateByPhone($restaurantId, $phone ?? 'unknown', $attributes); } /** * Detect if a value is a BSUID rather than a phone number. * BSUID format: CC.alphanumeric (e.g., "BR.1A2B3C4D5E...") */ public static function isBsuid(string $value): bool { $clean = str_replace('whatsapp:', '', $value); if (preg_match('/^[A-Z]{2}\.[A-Za-z0-9]+$/', $clean)) { return true; } return false; } /** * Normalize phone number for consistent lookup */ public static function normalizePhone(string $phone): string { // Remove whatsapp: prefix if present $phone = str_replace('whatsapp:', '', $phone); // If this is a BSUID, return as-is (don't strip alpha chars) if (self::isBsuid($phone)) { return $phone; } // Remove all non-numeric characters except + $phone = preg_replace('/[^0-9+]/', '', $phone); // Ensure starts with + if (strpos($phone, '+') !== 0) { $phone = '+' . $phone; } return $phone; } // ------------------------------------------------------------------------- // Customer Status Methods // ------------------------------------------------------------------------- /** * Set the customer interaction status */ public function setCustomerStatus(string $status): self { $this->customer_status = $status; $this->save(); return $this; } public function isIdle(): bool { return $this->customer_status === self::CUSTOMER_STATUS_IDLE || empty($this->customer_status); } public function isAwaitingReply(): bool { return $this->customer_status === self::CUSTOMER_STATUS_AWAITING_REPLY; } public function isHumanInteraction(): bool { return $this->customer_status === self::CUSTOMER_STATUS_HUMAN_INTERACTION; } /** * Timeout in seconds for HUMAN_INTERACTION status. * After this time without agent activity, the conversation * is automatically released back to IDLE. * * @return int Seconds (default: 7200 = 2 hours) */ public static function humanInteractionTimeoutSeconds(): int { return self::$humanInteractionTimeout; //return 7200; // 2 hours } /** * Check if the HUMAN_INTERACTION status has expired. * Compares the last agent message time against the timeout. * * @return bool */ public function isHumanInteractionExpired(): bool { if (!$this->isHumanInteraction()) { return false; } $lastAgentMessage = WhatsappMessage::where('conversation_id', $this->id) ->where('sender_type', 'agent') ->orderBy('id', 'DESC') ->limit(1) ->first(); if (!$lastAgentMessage) { // No agent message found but status is HUMAN_INTERACTION — expired return true; } $agentTime = strtotime($lastAgentMessage->date_created); $now = time(); return ($now - $agentTime) >= self::humanInteractionTimeoutSeconds(); } public function isBotInteraction(): bool { return $this->customer_status === self::CUSTOMER_STATUS_BOT_INTERACTION; } // ------------------------------------------------------------------------- // Active Booking Methods // ------------------------------------------------------------------------- /** * Set the active booking for this conversation. * Called when a reminder/reconfirmation/template is sent for a specific booking. */ public function setActiveBooking(int $bookingId): self { $this->active_booking_id = $bookingId; $this->save(); return $this; } public function clearActiveBooking(): self { $this->active_booking_id = null; $this->save(); return $this; } public function getActiveBookingId(): ?int { $id = $this->active_booking_id; return ($id && $id > 0) ? (int) $id : null; } // ------------------------------------------------------------------------- // Instance Methods // ------------------------------------------------------------------------- /** * Update window expiration after inbound message */ public function refreshWindow(): self { $this->window_expires_at = date('Y-m-d H:i:s', strtotime('+' . self::WINDOW_DURATION_HOURS . ' hours')); $this->last_inbound_at = evavel_now(); $this->last_message_at = evavel_now(); $this->save(); return $this; } /** * Mark conversation as having new outbound message */ public function markOutbound(): self { $this->last_outbound_at = evavel_now(); $this->last_message_at = evavel_now(); $this->message_count = ($this->message_count ?? 0) + 1; $this->save(); return $this; } /** * Mark conversation as having new inbound message */ public function markInbound(): self { $this->refreshWindow(); $this->unread_count = ($this->unread_count ?? 0) + 1; $this->message_count = ($this->message_count ?? 0) + 1; $this->save(); return $this; } /** * Mark all messages as read */ public function markAsRead(): self { $this->unread_count = 0; $this->save(); return $this; } /** * Close the conversation */ public function close(): self { $this->status = self::STATUS_CLOSED; $this->save(); return $this; } /** * Reopen the conversation */ public function reopen(): self { $this->status = self::STATUS_OPEN; $this->save(); return $this; } /** * Link conversation to a customer */ public function linkToCustomer(int $customerId): self { $this->customer_id = $customerId; $this->save(); return $this; } /** * Get conversations with open window */ public static function getOpenWindows(int $restaurantId) { return self::where('restaurant_id', $restaurantId) ->where('window_expires_at', '>', evavel_now()) ->orderBy('last_message_at', 'DESC') ->get(); } /** * Get conversations with unread messages */ public static function getUnread(int $restaurantId) { return self::where('restaurant_id', $restaurantId) ->where('unread_count', '>', 0) ->orderBy('last_message_at', 'DESC') ->get(); } /** * Convert to array */ public function toArray(): array { return [ 'id' => $this->id, 'restaurant_id' => $this->restaurant_id, 'customer_id' => $this->customer_id, 'active_booking_id' => $this->active_booking_id, 'phone' => $this->phone, 'phone_normalized' => $this->phone_normalized, 'profile_name' => $this->profile_name, 'wa_id' => $this->wa_id, 'bsuid' => $this->bsuid, 'status' => $this->status, 'customer_status' => $this->customer_status ?? self::CUSTOMER_STATUS_IDLE, 'is_window_open' => $this->isWindowOpen, 'window_expires_at' => $this->window_expires_at, 'window_remaining_seconds' => $this->windowRemainingSeconds, 'last_message_at' => $this->last_message_at, 'last_inbound_at' => $this->last_inbound_at, 'last_outbound_at' => $this->last_outbound_at, 'unread_count' => $this->unread_count, 'message_count' => $this->message_count, 'date_created' => $this->date_created, 'date_modified' => $this->date_modified, ]; } }