Chatwoot-style 3-panel chat interface:
- Left panel: conversation list with filters (status, assignee, search)
- Center panel: message thread with reply box and AI draft
- Right panel: contact details, assignment, internal notes
Features:
- Real-time thread loading via fetch API
- Keyboard shortcuts (Cmd+Enter to send)
- Collision detection heartbeat
- New conversation modal
- Thread status management (close/reopen)
- AI reply generation
- Internal notes
Routes added at /s/{business}/chat/*
Sidebar link added under Inbox section (requires Sales Suite)
411 lines
13 KiB
PHP
411 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Seller;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Business;
|
|
use App\Models\Crm\CrmActiveView;
|
|
use App\Models\Crm\CrmChannel;
|
|
use App\Models\Crm\CrmInternalNote;
|
|
use App\Models\Crm\CrmThread;
|
|
use App\Models\User;
|
|
use App\Services\Crm\CrmAiService;
|
|
use App\Services\Crm\CrmChannelService;
|
|
use App\Services\Crm\CrmSlaService;
|
|
use Illuminate\Http\Request;
|
|
|
|
class ChatController extends Controller
|
|
{
|
|
public function __construct(
|
|
protected CrmChannelService $channelService,
|
|
protected CrmSlaService $slaService,
|
|
protected CrmAiService $aiService
|
|
) {}
|
|
|
|
/**
|
|
* Unified chat inbox view (Chatwoot-style)
|
|
*/
|
|
public function index(Request $request, Business $business)
|
|
{
|
|
$query = CrmThread::forBusiness($business->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
|
|
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
|
->pluck('business_id')
|
|
->unique();
|
|
|
|
$contacts = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
|
|
->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 customer business IDs
|
|
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
|
->pluck('business_id')
|
|
->unique();
|
|
|
|
// SECURITY: Verify contact belongs to a customer business
|
|
$contact = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
|
|
->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,
|
|
]),
|
|
]);
|
|
}
|
|
}
|