Files
hub/app/Http/Controllers/Seller/Crm/ThreadController.php
kelly 05f77e6144 fix: ThreadController to use Business route model binding
All methods now accept Business $business as a route parameter instead
of incorrectly trying to access $request->user()->business which doesn't
exist in this app's architecture.
2025-12-03 13:04:14 -07:00

318 lines
9.3 KiB
PHP

<?php
namespace App\Http\Controllers\Seller\Crm;
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 ThreadController extends Controller
{
public function __construct(
protected CrmChannelService $channelService,
protected CrmSlaService $slaService,
protected CrmAiService $aiService
) {}
/**
* Display unified inbox
*/
public function index(Request $request, Business $business)
{
$query = CrmThread::forBusiness($business->id)
->with(['contact', 'assignee', '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);
}
if ($request->filled('search')) {
$query->where(function ($q) use ($request) {
$q->where('subject', 'like', "%{$request->search}%")
->orWhere('last_message_preview', 'like', "%{$request->search}%")
->orWhereHas('contact', fn ($c) => $c->where('name', 'like', "%{$request->search}%"));
});
}
// Default sort
$threads = $query->orderByDesc('last_message_at')
->paginate(25);
// Get team members for assignment dropdown
$teamMembers = User::where('business_id', $business->id)->get();
// Get available channels
$channels = $this->channelService->getAvailableChannels($business->id);
return view('seller.crm.threads.index', compact('threads', 'teamMembers', 'channels'));
}
/**
* 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',
'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);
return view('seller.crm.threads.show', compact(
'thread',
'otherViewers',
'slaStatus',
'suggestions',
'channels'
));
}
/**
* 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.']);
}
// 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'])
->where('business_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,
]),
]);
}
}