id) ->with(['contact', 'assignee', 'brand', 'channel', 'messages' => fn ($q) => $q->latest()->limit(1)]) ->withCount('messages'); // Filters if ($request->filled('status')) { $query->where('status', $request->status); } if ($request->filled('assigned_to')) { if ($request->assigned_to === 'unassigned') { $query->unassigned(); } else { $query->assignedTo($request->assigned_to); } } if ($request->filled('department')) { $query->forDepartment($request->department); } if ($request->filled('brand_id')) { $query->forBrand($request->brand_id); } if ($request->filled('search')) { $query->where(function ($q) use ($request) { $q->where('subject', 'ilike', "%{$request->search}%") ->orWhere('last_message_preview', 'ilike', "%{$request->search}%") ->orWhereHas('contact', fn ($c) => $c->where('name', 'ilike', "%{$request->search}%")); }); } $threads = $query->orderByDesc('last_message_at')->paginate(50); // Get team members for assignment dropdown $teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get(); // Get available channels $channels = $this->channelService->getAvailableChannels($business->id); // Get brands for filter dropdown $brands = \App\Models\Brand::where('business_id', $business->id) ->where('is_active', true) ->orderBy('name') ->get(); // Get departments for filter dropdown $departments = CrmChannel::DEPARTMENTS; // Get contacts for new conversation modal // Include: 1) Customer contacts (from businesses that ordered), 2) Own business contacts (coworkers) $customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id)) ->pluck('business_id') ->unique(); // Add the seller's own business ID to include coworkers $allBusinessIds = $customerBusinessIds->push($business->id)->unique(); $contacts = \App\Models\Contact::whereIn('business_id', $allBusinessIds) ->with('business:id,name') ->orderBy('first_name') ->limit(200) ->get(); return view('seller.chat.index', compact( 'business', 'threads', 'teamMembers', 'channels', 'brands', 'departments', 'contacts' )); } /** * API: Get thread data for inline loading */ public function getThread(Request $request, Business $business, CrmThread $thread) { if ($thread->business_id !== $business->id) { return response()->json(['error' => 'Not found'], 404); } $thread->load([ 'contact', 'account', 'assignee', 'brand', 'channel', 'messages.attachments', 'messages.user', 'deals', 'internalNotes.user', 'tags.tag', ]); // Mark as read $thread->markAsRead($request->user()); // Start viewing (collision detection) CrmActiveView::startViewing($thread, $request->user()); // Get other viewers $otherViewers = CrmActiveView::getActiveViewers($thread, $request->user()->id); // Get SLA status $slaStatus = $this->slaService->getThreadSlaStatus($thread); // Get AI suggestions $suggestions = $thread->aiSuggestions()->pending()->notExpired()->get(); // Get available channels for reply $channels = $this->channelService->getAvailableChannels($business->id); // Get team members for assignment dropdown $teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get(); return response()->json([ 'thread' => $thread, 'otherViewers' => $otherViewers->map(fn ($v) => [ 'id' => $v->user->id, 'name' => $v->user->name, 'type' => $v->view_type, ]), 'slaStatus' => $slaStatus, 'suggestions' => $suggestions, 'channels' => $channels, 'teamMembers' => $teamMembers, ]); } /** * API: Send reply in thread */ public function reply(Request $request, Business $business, CrmThread $thread) { if ($thread->business_id !== $business->id) { return response()->json(['error' => 'Not found'], 404); } $validated = $request->validate([ 'body' => 'required|string|max:10000', 'channel_type' => 'required|string|in:sms,email,whatsapp,instagram,in_app', ]); $contact = $thread->contact; $to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL ? $contact->email : $contact->phone; if (! $to) { return response()->json(['error' => 'Contact does not have required contact info for this channel.'], 422); } $success = $this->channelService->sendMessage( businessId: $business->id, channelType: $validated['channel_type'], to: $to, body: $validated['body'], subject: null, threadId: $thread->id, contactId: $contact->id, userId: $request->user()->id, attachments: [] ); if (! $success) { return response()->json(['error' => 'Failed to send message.'], 500); } // Auto-assign thread to sender if unassigned if ($thread->assigned_to === null) { $thread->assigned_to = $request->user()->id; $thread->save(); } // Handle SLA $this->slaService->handleOutboundMessage($thread); // Reload messages $thread->load(['messages.attachments', 'messages.user']); return response()->json([ 'success' => true, 'messages' => $thread->messages, ]); } /** * API: Create new thread */ public function store(Request $request, Business $business) { $validated = $request->validate([ 'contact_id' => 'required|exists:contacts,id', 'channel_type' => 'required|string|in:sms,email,whatsapp,instagram,in_app', 'body' => 'required|string|max:10000', ]); // Get allowed business IDs (customers + own business for coworkers) $customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id)) ->pluck('business_id') ->unique(); $allBusinessIds = $customerBusinessIds->push($business->id)->unique(); // SECURITY: Verify contact belongs to a customer business or own business (coworker) $contact = \App\Models\Contact::whereIn('business_id', $allBusinessIds) ->findOrFail($validated['contact_id']); $to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL ? $contact->email : $contact->phone; if (! $to) { return response()->json(['error' => 'Contact does not have the required contact info for this channel.'], 422); } // Create thread $thread = CrmThread::create([ 'business_id' => $business->id, 'contact_id' => $contact->id, 'account_id' => $contact->account_id, 'status' => 'open', 'priority' => 'normal', 'last_channel_type' => $validated['channel_type'], 'assigned_to' => $request->user()->id, ]); // Send the message $success = $this->channelService->sendMessage( businessId: $business->id, channelType: $validated['channel_type'], to: $to, body: $validated['body'], subject: null, threadId: $thread->id, contactId: $contact->id, userId: $request->user()->id, attachments: [] ); if (! $success) { $thread->delete(); return response()->json(['error' => 'Failed to send message.'], 500); } $thread->load(['contact', 'messages']); return response()->json([ 'success' => true, 'thread' => $thread, ]); } /** * API: Assign thread */ public function assign(Request $request, Business $business, CrmThread $thread) { if ($thread->business_id !== $business->id) { return response()->json(['error' => 'Not found'], 404); } $validated = $request->validate([ 'assigned_to' => 'nullable|exists:users,id', ]); if ($validated['assigned_to']) { $assignee = User::where('id', $validated['assigned_to']) ->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id)) ->first(); if (! $assignee) { return response()->json(['error' => 'Invalid user.'], 422); } $thread->assignTo($assignee, $request->user()); } else { $thread->assigned_to = null; $thread->save(); } return response()->json(['success' => true]); } /** * API: Close thread */ public function close(Request $request, Business $business, CrmThread $thread) { if ($thread->business_id !== $business->id) { return response()->json(['error' => 'Not found'], 404); } $thread->close($request->user()); return response()->json(['success' => true, 'status' => 'closed']); } /** * API: Reopen thread */ public function reopen(Request $request, Business $business, CrmThread $thread) { if ($thread->business_id !== $business->id) { return response()->json(['error' => 'Not found'], 404); } $thread->reopen($request->user()); $this->slaService->resumeTimers($thread); return response()->json(['success' => true, 'status' => 'open']); } /** * API: Add internal note */ public function addNote(Request $request, Business $business, CrmThread $thread) { if ($thread->business_id !== $business->id) { return response()->json(['error' => 'Not found'], 404); } $validated = $request->validate([ 'content' => 'required|string|max:5000', ]); $note = CrmInternalNote::create([ 'business_id' => $business->id, 'user_id' => $request->user()->id, 'notable_type' => CrmThread::class, 'notable_id' => $thread->id, 'content' => $validated['content'], ]); $note->load('user'); return response()->json(['success' => true, 'note' => $note]); } /** * API: Generate AI reply */ public function generateAiReply(Request $request, Business $business, CrmThread $thread) { if ($thread->business_id !== $business->id) { return response()->json(['error' => 'Not found'], 404); } $suggestion = $this->aiService->generateReplyDraft($thread, $request->input('tone', 'professional')); if (! $suggestion) { return response()->json(['error' => 'Failed to generate reply.'], 500); } return response()->json([ 'content' => $suggestion->content, 'suggestion_id' => $suggestion->id, ]); } /** * API: Heartbeat for active viewing */ public function heartbeat(Request $request, Business $business, CrmThread $thread) { if ($thread->business_id !== $business->id) { return response()->json(['error' => 'Unauthorized'], 403); } CrmActiveView::startViewing($thread, $request->user(), $request->input('view_type', 'viewing')); $otherViewers = CrmActiveView::getActiveViewers($thread, $request->user()->id); return response()->json([ 'other_viewers' => $otherViewers->map(fn ($v) => [ 'id' => $v->user->id, 'name' => $v->user->name, 'type' => $v->view_type, ]), ]); } }