Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Fix broadcast event names (.message.new, .typing, .thread.updated, .agent.status) - Add / trigger for quick replies in composer - Include agent status widget in inbox header - Pass team member statuses from AgentStatus table - Update useQuickReply to remove / trigger when inserting
816 lines
28 KiB
PHP
816 lines
28 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Seller\Crm;
|
|
|
|
use App\Events\CrmTypingIndicator;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\AgentStatus;
|
|
use App\Models\Business;
|
|
use App\Models\ChatQuickReply;
|
|
use App\Models\Contact;
|
|
use App\Models\Crm\CrmActiveView;
|
|
use App\Models\Crm\CrmChannel;
|
|
use App\Models\Crm\CrmInternalNote;
|
|
use App\Models\Crm\CrmThread;
|
|
use App\Models\SalesRepAssignment;
|
|
use App\Models\User;
|
|
use App\Services\Crm\CrmAiService;
|
|
use App\Services\Crm\CrmChannelService;
|
|
use App\Services\Crm\CrmSlaService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
class ThreadController extends Controller
|
|
{
|
|
public function __construct(
|
|
protected CrmChannelService $channelService,
|
|
protected CrmSlaService $slaService,
|
|
protected CrmAiService $aiService
|
|
) {}
|
|
|
|
/**
|
|
* Show compose form for new thread
|
|
*/
|
|
public function create(Request $request, Business $business)
|
|
{
|
|
// Get customer business IDs (businesses that have ordered from this seller)
|
|
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
|
->pluck('business_id')
|
|
->unique();
|
|
|
|
// Get contacts from customer businesses (accounts)
|
|
$contacts = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
|
|
->with('business:id,name')
|
|
->orderBy('first_name')
|
|
->limit(200)
|
|
->get();
|
|
|
|
// Get available channels
|
|
$channels = $this->channelService->getAvailableChannels($business->id);
|
|
|
|
// Pre-select contact if provided
|
|
$selectedContact = null;
|
|
if ($request->filled('contact_id')) {
|
|
$selectedContact = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
|
|
->find($request->contact_id);
|
|
}
|
|
|
|
return view('seller.crm.threads.create', compact('business', 'contacts', 'channels', 'selectedContact'));
|
|
}
|
|
|
|
/**
|
|
* Store a new thread and send initial message
|
|
*/
|
|
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',
|
|
'subject' => 'nullable|string|max:255',
|
|
'body' => 'required|string|max:10000',
|
|
'attachments.*' => 'nullable|file|max:10240',
|
|
]);
|
|
|
|
// Get customer business IDs (businesses that have ordered from this seller)
|
|
$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']);
|
|
|
|
// Determine recipient address
|
|
$to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL
|
|
? $contact->email
|
|
: $contact->phone;
|
|
|
|
if (! $to) {
|
|
return back()->withInput()->withErrors([
|
|
'channel_type' => 'Contact does not have the required contact info for this channel.',
|
|
]);
|
|
}
|
|
|
|
// Create thread first
|
|
$thread = CrmThread::create([
|
|
'business_id' => $business->id,
|
|
'contact_id' => $contact->id,
|
|
'account_id' => $contact->account_id,
|
|
'subject' => $validated['subject'],
|
|
'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: $validated['subject'] ?? null,
|
|
threadId: $thread->id,
|
|
contactId: $contact->id,
|
|
userId: $request->user()->id,
|
|
attachments: $request->file('attachments', [])
|
|
);
|
|
|
|
if (! $success) {
|
|
// Delete the thread if message failed
|
|
$thread->delete();
|
|
|
|
return back()->withInput()->withErrors(['body' => 'Failed to send message.']);
|
|
}
|
|
|
|
return redirect()
|
|
->route('seller.business.crm.threads.show', [$business, $thread])
|
|
->with('success', 'Conversation started successfully.');
|
|
}
|
|
|
|
/**
|
|
* Display unified inbox
|
|
*/
|
|
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('channel')) {
|
|
$query->where('last_channel_type', $request->channel);
|
|
}
|
|
|
|
if ($request->filled('priority')) {
|
|
$query->withPriority($request->priority);
|
|
}
|
|
|
|
// Department filter
|
|
if ($request->filled('department')) {
|
|
$query->forDepartment($request->department);
|
|
}
|
|
|
|
// Brand filter
|
|
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}%"));
|
|
});
|
|
}
|
|
|
|
// Default sort
|
|
$threads = $query->orderByDesc('last_message_at')
|
|
->paginate(25);
|
|
|
|
// 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;
|
|
|
|
return view('seller.crm.threads.index', compact('business', 'threads', 'teamMembers', 'channels', 'brands', 'departments'));
|
|
}
|
|
|
|
/**
|
|
* Show a single thread
|
|
*/
|
|
public function show(Request $request, Business $business, CrmThread $thread)
|
|
{
|
|
// SECURITY: Verify business ownership
|
|
if ($thread->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
// Load relationships
|
|
$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 view('seller.crm.threads.show', compact(
|
|
'business',
|
|
'thread',
|
|
'otherViewers',
|
|
'slaStatus',
|
|
'suggestions',
|
|
'channels',
|
|
'teamMembers'
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Send a reply in thread
|
|
*/
|
|
public function reply(Request $request, Business $business, CrmThread $thread)
|
|
{
|
|
if ($thread->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'body' => 'required|string|max:10000',
|
|
'channel_type' => 'required|string|in:sms,email,whatsapp,instagram,in_app',
|
|
'subject' => 'nullable|string|max:255',
|
|
'attachments.*' => 'nullable|file|max:10240',
|
|
]);
|
|
|
|
$contact = $thread->contact;
|
|
$to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL
|
|
? $contact->email
|
|
: $contact->phone;
|
|
|
|
if (! $to) {
|
|
return back()->withErrors(['channel_type' => 'Contact does not have required contact info for this channel.']);
|
|
}
|
|
|
|
$success = $this->channelService->sendMessage(
|
|
businessId: $business->id,
|
|
channelType: $validated['channel_type'],
|
|
to: $to,
|
|
body: $validated['body'],
|
|
subject: $validated['subject'] ?? null,
|
|
threadId: $thread->id,
|
|
contactId: $contact->id,
|
|
userId: $request->user()->id,
|
|
attachments: $request->file('attachments', [])
|
|
);
|
|
|
|
if (! $success) {
|
|
return back()->withErrors(['body' => 'Failed to send message.']);
|
|
}
|
|
|
|
// 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);
|
|
|
|
return back()->with('success', 'Message sent successfully.');
|
|
}
|
|
|
|
/**
|
|
* Assign thread to user
|
|
*/
|
|
public function assign(Request $request, Business $business, CrmThread $thread)
|
|
{
|
|
if ($thread->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'assigned_to' => 'required|exists:users,id',
|
|
]);
|
|
|
|
// SECURITY: Verify user belongs to business
|
|
$assignee = User::where('id', $validated['assigned_to'])
|
|
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
|
->first();
|
|
|
|
if (! $assignee) {
|
|
return back()->withErrors(['assigned_to' => 'Invalid user.']);
|
|
}
|
|
|
|
$thread->assignTo($assignee, $request->user());
|
|
|
|
return back()->with('success', "Thread assigned to {$assignee->name}.");
|
|
}
|
|
|
|
/**
|
|
* Close thread
|
|
*/
|
|
public function close(Request $request, Business $business, CrmThread $thread)
|
|
{
|
|
if ($thread->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
$thread->close($request->user());
|
|
|
|
return back()->with('success', 'Thread closed.');
|
|
}
|
|
|
|
/**
|
|
* Reopen thread
|
|
*/
|
|
public function reopen(Request $request, Business $business, CrmThread $thread)
|
|
{
|
|
if ($thread->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
$thread->reopen($request->user());
|
|
|
|
// Restart SLA timers
|
|
$this->slaService->resumeTimers($thread);
|
|
|
|
return back()->with('success', 'Thread reopened.');
|
|
}
|
|
|
|
/**
|
|
* Snooze thread
|
|
*/
|
|
public function snooze(Request $request, Business $business, CrmThread $thread)
|
|
{
|
|
if ($thread->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'until' => 'required|date|after:now',
|
|
]);
|
|
|
|
$thread->snooze(new \DateTime($validated['until']), $request->user());
|
|
|
|
// Pause SLA timers
|
|
$this->slaService->pauseTimers($thread);
|
|
|
|
return back()->with('success', 'Thread snoozed until '.\Carbon\Carbon::parse($validated['until'])->format('M j, g:i A'));
|
|
}
|
|
|
|
/**
|
|
* Add internal note
|
|
*/
|
|
public function addNote(Request $request, Business $business, CrmThread $thread)
|
|
{
|
|
if ($thread->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'content' => 'required|string|max:5000',
|
|
]);
|
|
|
|
CrmInternalNote::create([
|
|
'business_id' => $business->id,
|
|
'user_id' => $request->user()->id,
|
|
'notable_type' => CrmThread::class,
|
|
'notable_id' => $thread->id,
|
|
'content' => $validated['content'],
|
|
]);
|
|
|
|
return back()->with('success', 'Note added.');
|
|
}
|
|
|
|
/**
|
|
* Generate AI reply draft
|
|
*/
|
|
public function generateAiReply(Request $request, Business $business, CrmThread $thread)
|
|
{
|
|
if ($thread->business_id !== $business->id) {
|
|
abort(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,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
]),
|
|
]);
|
|
}
|
|
|
|
// ========================================
|
|
// API Endpoints for Real-Time Inbox
|
|
// ========================================
|
|
|
|
/**
|
|
* API: Get threads list for real-time updates
|
|
*/
|
|
public function apiIndex(Request $request, Business $business): JsonResponse
|
|
{
|
|
$user = $request->user();
|
|
|
|
$query = CrmThread::forBusiness($business->id)
|
|
->with(['contact:id,first_name,last_name,email,phone', 'assignee:id,name', 'channel:id,type,name', 'account:id,name'])
|
|
->withCount('messages');
|
|
|
|
// Apply "my accounts" filter for sales reps
|
|
if ($request->boolean('my_accounts')) {
|
|
$query->forSalesRep($business->id, $user->id);
|
|
}
|
|
|
|
// Status filter
|
|
if ($request->filled('status') && $request->status !== 'all') {
|
|
$query->where('status', $request->status);
|
|
}
|
|
|
|
// Channel filter
|
|
if ($request->filled('channel') && $request->channel !== 'all') {
|
|
$query->where('last_channel_type', $request->channel);
|
|
}
|
|
|
|
// Assigned filter
|
|
if ($request->filled('assigned')) {
|
|
if ($request->assigned === 'me') {
|
|
$query->where('assigned_to', $user->id);
|
|
} elseif ($request->assigned === 'unassigned') {
|
|
$query->whereNull('assigned_to');
|
|
} elseif (is_numeric($request->assigned)) {
|
|
$query->where('assigned_to', $request->assigned);
|
|
}
|
|
}
|
|
|
|
// Search
|
|
if ($request->filled('search')) {
|
|
$search = $request->search;
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('subject', 'ilike', "%{$search}%")
|
|
->orWhere('last_message_preview', 'ilike', "%{$search}%")
|
|
->orWhereHas('contact', fn ($c) => $c->whereRaw("CONCAT(first_name, ' ', last_name) ILIKE ?", ["%{$search}%"]))
|
|
->orWhereHas('account', fn ($a) => $a->where('name', 'ilike', "%{$search}%"));
|
|
});
|
|
}
|
|
|
|
$threads = $query->orderByDesc('last_message_at')
|
|
->limit($request->input('limit', 50))
|
|
->get();
|
|
|
|
return response()->json([
|
|
'threads' => $threads->map(fn ($t) => [
|
|
'id' => $t->id,
|
|
'subject' => $t->subject,
|
|
'status' => $t->status,
|
|
'priority' => $t->priority,
|
|
'is_read' => $t->is_read,
|
|
'last_message_at' => $t->last_message_at?->toIso8601String(),
|
|
'last_message_preview' => $t->last_message_preview,
|
|
'last_message_direction' => $t->last_message_direction,
|
|
'last_channel_type' => $t->last_channel_type,
|
|
'contact' => $t->contact ? [
|
|
'id' => $t->contact->id,
|
|
'name' => $t->contact->getFullName(),
|
|
'email' => $t->contact->email,
|
|
'phone' => $t->contact->phone,
|
|
] : null,
|
|
'account' => $t->account ? [
|
|
'id' => $t->account->id,
|
|
'name' => $t->account->name,
|
|
] : null,
|
|
'assignee' => $t->assignee ? [
|
|
'id' => $t->assignee->id,
|
|
'name' => $t->assignee->name,
|
|
] : null,
|
|
'messages_count' => $t->messages_count,
|
|
]),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* API: Get messages for a thread
|
|
*/
|
|
public function apiMessages(Request $request, Business $business, CrmThread $thread): JsonResponse
|
|
{
|
|
if ($thread->business_id !== $business->id) {
|
|
return response()->json(['error' => 'Unauthorized'], 403);
|
|
}
|
|
|
|
$query = $thread->messages()
|
|
->with(['user:id,name', 'attachments'])
|
|
->orderBy('created_at', 'asc');
|
|
|
|
// Pagination for infinite scroll
|
|
if ($request->filled('before_id')) {
|
|
$query->where('id', '<', $request->before_id);
|
|
}
|
|
|
|
$messages = $query->limit($request->input('limit', 50))->get();
|
|
|
|
// Mark thread as read
|
|
if ($messages->isNotEmpty()) {
|
|
$thread->markAsRead($request->user());
|
|
}
|
|
|
|
return response()->json([
|
|
'messages' => $messages->map(fn ($m) => [
|
|
'id' => $m->id,
|
|
'body' => $m->body,
|
|
'body_html' => $m->body_html,
|
|
'direction' => $m->direction,
|
|
'channel_type' => $m->channel_type,
|
|
'sender_id' => $m->user_id,
|
|
'sender_name' => $m->user?->name ?? ($m->direction === 'inbound' ? $thread->contact?->getFullName() : 'System'),
|
|
'status' => $m->status,
|
|
'created_at' => $m->created_at->toIso8601String(),
|
|
'attachments' => $m->attachments->map(fn ($a) => [
|
|
'id' => $a->id,
|
|
'filename' => $a->original_filename ?? $a->filename,
|
|
'mime_type' => $a->mime_type,
|
|
'size' => $a->size,
|
|
'url' => Storage::disk($a->disk ?? 'minio')->url($a->path),
|
|
]),
|
|
]),
|
|
'has_more' => $messages->count() === $request->input('limit', 50),
|
|
'thread' => [
|
|
'id' => $thread->id,
|
|
'subject' => $thread->subject,
|
|
'status' => $thread->status,
|
|
'contact' => $thread->contact ? [
|
|
'id' => $thread->contact->id,
|
|
'name' => $thread->contact->getFullName(),
|
|
'email' => $thread->contact->email,
|
|
'phone' => $thread->contact->phone,
|
|
] : null,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* API: Send typing indicator
|
|
*/
|
|
public function typing(Request $request, Business $business, CrmThread $thread): JsonResponse
|
|
{
|
|
if ($thread->business_id !== $business->id) {
|
|
return response()->json(['error' => 'Unauthorized'], 403);
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'is_typing' => 'required|boolean',
|
|
]);
|
|
|
|
broadcast(new CrmTypingIndicator(
|
|
threadId: $thread->id,
|
|
userId: $request->user()->id,
|
|
userName: $request->user()->name,
|
|
isTyping: $validated['is_typing']
|
|
))->toOthers();
|
|
|
|
// Update active view type
|
|
CrmActiveView::startViewing(
|
|
$thread,
|
|
$request->user(),
|
|
$validated['is_typing'] ? CrmActiveView::VIEW_TYPE_TYPING : CrmActiveView::VIEW_TYPE_VIEWING
|
|
);
|
|
|
|
return response()->json(['success' => true]);
|
|
}
|
|
|
|
/**
|
|
* API: Get quick replies
|
|
*/
|
|
public function quickReplies(Request $request, Business $business): JsonResponse
|
|
{
|
|
$quickReplies = ChatQuickReply::where('business_id', $business->id)
|
|
->where('is_active', true)
|
|
->orderByDesc('usage_count')
|
|
->orderBy('sort_order')
|
|
->get()
|
|
->groupBy('category');
|
|
|
|
return response()->json([
|
|
'quick_replies' => $quickReplies,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* API: Use a quick reply (increment usage count)
|
|
*/
|
|
public function useQuickReply(Request $request, Business $business, ChatQuickReply $quickReply): JsonResponse
|
|
{
|
|
if ($quickReply->business_id !== $business->id) {
|
|
return response()->json(['error' => 'Unauthorized'], 403);
|
|
}
|
|
|
|
// Increment usage count
|
|
$quickReply->increment('usage_count');
|
|
|
|
// Process template variables
|
|
$message = $quickReply->message;
|
|
|
|
if ($request->filled('contact_id')) {
|
|
$contact = Contact::find($request->contact_id);
|
|
if ($contact) {
|
|
$message = str_replace(
|
|
['{{name}}', '{{first_name}}', '{{last_name}}', '{{company}}'],
|
|
[$contact->getFullName(), $contact->first_name, $contact->last_name, $contact->business?->name ?? ''],
|
|
$message
|
|
);
|
|
}
|
|
}
|
|
|
|
return response()->json([
|
|
'message' => $message,
|
|
'label' => $quickReply->label,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* API: Get contact details with email engagement
|
|
*/
|
|
public function apiContact(Request $request, Business $business, CrmThread $thread): JsonResponse
|
|
{
|
|
if ($thread->business_id !== $business->id) {
|
|
return response()->json(['error' => 'Unauthorized'], 403);
|
|
}
|
|
|
|
$contact = $thread->contact;
|
|
if (! $contact) {
|
|
return response()->json(['contact' => null]);
|
|
}
|
|
|
|
// Get recent email engagement
|
|
$emailEngagement = [];
|
|
if (class_exists(\App\Models\Analytics\EmailInteraction::class)) {
|
|
$emailEngagement = \App\Models\Analytics\EmailInteraction::where(function ($q) use ($contact) {
|
|
$q->where('recipient_email', $contact->email);
|
|
if ($contact->user_id) {
|
|
$q->orWhere('recipient_user_id', $contact->user_id);
|
|
}
|
|
})
|
|
->whereNotNull('first_opened_at')
|
|
->with('emailCampaign:id,subject')
|
|
->orderByDesc('first_opened_at')
|
|
->limit(10)
|
|
->get()
|
|
->map(fn ($i) => [
|
|
'id' => $i->id,
|
|
'campaign_subject' => $i->emailCampaign?->subject ?? 'Unknown Campaign',
|
|
'opened_at' => $i->first_opened_at?->toIso8601String(),
|
|
'open_count' => $i->open_count,
|
|
'clicked_at' => $i->first_clicked_at?->toIso8601String(),
|
|
'click_count' => $i->click_count,
|
|
]);
|
|
}
|
|
|
|
// Get recent orders from this contact's account
|
|
$recentOrders = [];
|
|
if ($thread->account_id) {
|
|
$recentOrders = \App\Models\Order::where('business_id', $thread->account_id)
|
|
->whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
|
->orderByDesc('created_at')
|
|
->limit(5)
|
|
->get()
|
|
->map(fn ($o) => [
|
|
'id' => $o->id,
|
|
'hashid' => $o->hashid,
|
|
'total' => $o->total,
|
|
'status' => $o->status,
|
|
'created_at' => $o->created_at->toIso8601String(),
|
|
]);
|
|
}
|
|
|
|
return response()->json([
|
|
'contact' => [
|
|
'id' => $contact->id,
|
|
'name' => $contact->getFullName(),
|
|
'email' => $contact->email,
|
|
'phone' => $contact->phone,
|
|
'title' => $contact->title,
|
|
'contact_type' => $contact->contact_type,
|
|
],
|
|
'account' => $thread->account ? [
|
|
'id' => $thread->account->id,
|
|
'name' => $thread->account->name,
|
|
'address' => $thread->account->full_address ?? null,
|
|
] : null,
|
|
'email_engagement' => $emailEngagement,
|
|
'recent_orders' => $recentOrders,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Unified inbox view (Chatwoot-style)
|
|
*/
|
|
public function unified(Request $request, Business $business)
|
|
{
|
|
$user = $request->user();
|
|
|
|
// Get initial threads
|
|
$query = CrmThread::forBusiness($business->id)
|
|
->with(['contact:id,first_name,last_name,email,phone', 'assignee:id,name', 'account:id,name'])
|
|
->withCount('messages')
|
|
->orderByDesc('last_message_at')
|
|
->limit(50);
|
|
|
|
$threads = $query->get();
|
|
|
|
// Get team members with their status
|
|
$teamMemberStatuses = AgentStatus::where('business_id', $business->id)
|
|
->where('last_seen_at', '>=', now()->subMinutes(5))
|
|
->pluck('status', 'user_id');
|
|
|
|
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
|
->select('id', 'first_name', 'last_name')
|
|
->get()
|
|
->map(fn ($member) => [
|
|
'id' => $member->id,
|
|
'name' => trim($member->first_name.' '.$member->last_name),
|
|
'status' => $teamMemberStatuses[$member->id] ?? 'offline',
|
|
]);
|
|
|
|
// Get agent status
|
|
$agentStatus = AgentStatus::where('business_id', $business->id)
|
|
->where('user_id', $user->id)
|
|
->first();
|
|
|
|
// Get quick replies
|
|
$quickReplies = ChatQuickReply::where('business_id', $business->id)
|
|
->where('is_active', true)
|
|
->orderByDesc('usage_count')
|
|
->get()
|
|
->groupBy('category');
|
|
|
|
// Get channels
|
|
$channels = $this->channelService->getAvailableChannels($business->id);
|
|
|
|
// Check if user has sales rep assignments (for "My Accounts" filter)
|
|
$hasSalesRepAssignments = SalesRepAssignment::where('business_id', $business->id)
|
|
->where('user_id', $user->id)
|
|
->exists();
|
|
|
|
return view('seller.crm.inbox.unified', compact(
|
|
'business',
|
|
'threads',
|
|
'teamMembers',
|
|
'agentStatus',
|
|
'quickReplies',
|
|
'channels',
|
|
'hasSalesRepAssignments'
|
|
));
|
|
}
|
|
}
|