Merge pull request 'fix: CRM controller fixes and missing migrations' (#125) from fix/buyer-crm-namespaces into develop

Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/125
This commit is contained in:
kelly
2025-12-05 23:31:05 +00:00
18 changed files with 289 additions and 72 deletions

View File

@@ -6,10 +6,10 @@ use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Buyer\BuyerBrandFollow;
use App\Models\Buyer\BuyerProductBookmark;
use App\Models\Crm\CrmThread;
use App\Models\Seller\BrandAnnouncement;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Crm\Entities\CrmThread;
class BrandHubController extends Controller
{

View File

@@ -7,12 +7,12 @@ use App\Models\Buyer\BuyerAnalyticsCache;
use App\Models\Buyer\BuyerBrandFollow;
use App\Models\Buyer\BuyerQuoteApproval;
use App\Models\Buyer\BuyerTask;
use App\Models\Crm\CrmInvoice;
use App\Models\Crm\CrmQuote;
use App\Models\Crm\CrmThread;
use App\Models\Order;
use App\Models\Seller\BrandAnnouncement;
use Illuminate\Support\Facades\Auth;
use Modules\Crm\Entities\CrmInvoice;
use Modules\Crm\Entities\CrmQuote;
use Modules\Crm\Entities\CrmThread;
class DashboardController extends Controller
{

View File

@@ -4,9 +4,9 @@ namespace App\Http\Controllers\Buyer\Crm;
use App\Http\Controllers\Controller;
use App\Models\Buyer\BuyerMessageSettings;
use App\Models\Crm\CrmThread;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Crm\Entities\CrmThread;
class InboxController extends Controller
{

View File

@@ -5,9 +5,10 @@ namespace App\Http\Controllers\Buyer\Crm;
use App\Http\Controllers\Controller;
use App\Models\Buyer\BuyerInvoiceRecord;
use App\Models\Buyer\BuyerSavedFilter;
use App\Models\Crm\CrmInvoice;
use App\Models\Crm\CrmThread;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Crm\Entities\CrmInvoice;
class InvoiceController extends Controller
{
@@ -115,7 +116,7 @@ class InvoiceController extends Controller
}
// Get related thread if exists
$thread = \Modules\Crm\Entities\CrmThread::where('buyer_business_id', $business->id)
$thread = CrmThread::where('buyer_business_id', $business->id)
->where(function ($q) use ($invoice) {
$q->where('order_id', $invoice->order_id)
->orWhere('subject', 'ilike', "%{$invoice->invoice_number}%");

View File

@@ -3,10 +3,10 @@
namespace App\Http\Controllers\Buyer\Crm;
use App\Http\Controllers\Controller;
use App\Models\Crm\CrmChannelMessage;
use App\Models\Crm\CrmThread;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Crm\Entities\CrmMessage;
use Modules\Crm\Entities\CrmThread;
class MessageController extends Controller
{
@@ -64,7 +64,7 @@ class MessageController extends Controller
return back()->with('success', 'Message sent.');
}
public function destroy(CrmThread $thread, CrmMessage $message)
public function destroy(CrmThread $thread, CrmChannelMessage $message)
{
$business = Auth::user()->business;
$user = Auth::user();
@@ -88,7 +88,7 @@ class MessageController extends Controller
return back()->with('success', 'Message deleted.');
}
public function react(Request $request, CrmThread $thread, CrmMessage $message)
public function react(Request $request, CrmThread $thread, CrmChannelMessage $message)
{
$business = Auth::user()->business;
$user = Auth::user();

View File

@@ -108,7 +108,7 @@ class OrderController extends Controller
$deliveryEvents = BuyerDeliveryEvent::getTimelineForOrder($order->id);
// Get related thread if exists
$thread = \Modules\Crm\Entities\CrmThread::where('order_id', $order->id)
$thread = \App\Models\Crm\CrmThread::where('order_id', $order->id)
->where('buyer_business_id', $business->id)
->first();

View File

@@ -6,9 +6,9 @@ use App\Http\Controllers\Controller;
use App\Models\Buyer\BuyerQuoteApproval;
use App\Models\Buyer\BuyerSavedFilter;
use App\Models\Buyer\BuyerTeamMember;
use App\Models\Crm\CrmQuote;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Crm\Entities\CrmQuote;
class QuoteController extends Controller
{

View File

@@ -32,15 +32,15 @@ class CrmCalendarController extends Controller
$startDate = $request->input('start', now()->startOfMonth());
$endDate = $request->input('end', now()->endOfMonth());
$events = CrmSyncedEvent::whereIn('connection_id', $connections->pluck('id'))
->whereBetween('start_time', [$startDate, $endDate])
$events = CrmSyncedEvent::whereIn('calendar_connection_id', $connections->pluck('id'))
->whereBetween('start_at', [$startDate, $endDate])
->get()
->map(fn ($e) => [
'id' => $e->id,
'title' => $e->title,
'start' => $e->start_time->toIso8601String(),
'end' => $e->end_time->toIso8601String(),
'allDay' => $e->is_all_day,
'start' => $e->start_at->toIso8601String(),
'end' => $e->end_at?->toIso8601String(),
'allDay' => $e->all_day,
'color' => $e->connection->provider === 'google' ? '#4285f4' : '#0078d4',
'extendedProps' => [
'location' => $e->location,
@@ -50,23 +50,23 @@ class CrmCalendarController extends Controller
]);
// Get meeting bookings
$bookings = \Modules\Crm\Entities\CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
$bookings = \App\Models\Crm\CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
$q->where('business_id', $business->id)
->where('user_id', $user->id);
})
->whereBetween('start_time', [$startDate, $endDate])
->whereBetween('start_at', [$startDate, $endDate])
->with(['meetingLink', 'contact'])
->get()
->map(fn ($b) => [
'id' => 'booking_'.$b->id,
'title' => $b->meetingLink->name.' - '.$b->guest_name,
'start' => $b->start_time->toIso8601String(),
'end' => $b->end_time->toIso8601String(),
'title' => $b->meetingLink->name.' - '.$b->booker_name,
'start' => $b->start_at->toIso8601String(),
'end' => $b->end_at->toIso8601String(),
'color' => '#10b981',
'extendedProps' => [
'type' => 'booking',
'contact_id' => $b->contact_id,
'guest_email' => $b->guest_email,
'booker_email' => $b->booker_email,
],
]);
@@ -253,15 +253,15 @@ class CrmCalendarController extends Controller
->where('user_id', $user->id)
->pluck('id');
$events = CrmSyncedEvent::whereIn('connection_id', $connections)
->whereBetween('start_time', [$validated['start'], $validated['end']])
$events = CrmSyncedEvent::whereIn('calendar_connection_id', $connections)
->whereBetween('start_at', [$validated['start'], $validated['end']])
->get()
->map(fn ($e) => [
'id' => $e->id,
'title' => $e->title,
'start' => $e->start_time->toIso8601String(),
'end' => $e->end_time->toIso8601String(),
'allDay' => $e->is_all_day,
'start' => $e->start_at->toIso8601String(),
'end' => $e->end_at?->toIso8601String(),
'allDay' => $e->all_day,
]);
return response()->json($events);

View File

@@ -16,13 +16,17 @@ class TaskController extends Controller
{
$user = $request->user();
$tasksQuery = CrmTask::where('business_id', $business->id)
->with(['assignee', 'creator', 'related'])
->orderBy('due_date');
$tasksQuery = CrmTask::where('seller_business_id', $business->id)
->with(['assignee', 'creator', 'contact', 'business'])
->orderBy('due_at');
// Filter by status
// Filter by status (completed vs incomplete)
if ($request->filled('status')) {
$tasksQuery->where('status', $request->status);
if ($request->status === 'completed') {
$tasksQuery->whereNotNull('completed_at');
} elseif ($request->status === 'pending') {
$tasksQuery->whereNull('completed_at');
}
}
// Filter by assignee
@@ -39,17 +43,17 @@ class TaskController extends Controller
// Get stats
$stats = [
'my_tasks' => CrmTask::where('business_id', $business->id)
'my_tasks' => CrmTask::where('seller_business_id', $business->id)
->where('assigned_to', $user->id)
->where('status', '!=', 'completed')
->whereNull('completed_at')
->count(),
'overdue' => CrmTask::where('business_id', $business->id)
->where('status', '!=', 'completed')
->where('due_date', '<', now())
'overdue' => CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->where('due_at', '<', now())
->count(),
'due_today' => CrmTask::where('business_id', $business->id)
->where('status', '!=', 'completed')
->whereDate('due_date', today())
'due_today' => CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->whereDate('due_at', today())
->count(),
];
@@ -71,19 +75,26 @@ class TaskController extends Controller
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'type' => 'required|in:call,email,meeting,task,follow_up',
'priority' => 'required|in:low,medium,high,urgent',
'due_date' => 'required|date',
'details' => 'nullable|string',
'type' => 'required|in:call,email,meeting,follow_up,demo,other',
'priority' => 'required|in:low,normal,high,urgent',
'due_at' => 'required|date',
'assigned_to' => 'nullable|exists:users,id',
'contact_id' => 'nullable|exists:contacts,id',
'business_id' => 'nullable|exists:businesses,id',
]);
$task = CrmTask::create([
...$validated,
'business_id' => $business->id,
'title' => $validated['title'],
'details' => $validated['details'] ?? null,
'type' => $validated['type'],
'priority' => $validated['priority'],
'due_at' => $validated['due_at'],
'contact_id' => $validated['contact_id'] ?? null,
'business_id' => $validated['business_id'] ?? null,
'seller_business_id' => $business->id,
'created_by' => $request->user()->id,
'assigned_to' => $validated['assigned_to'] ?? $request->user()->id,
'status' => 'pending',
]);
return redirect()
@@ -96,7 +107,7 @@ class TaskController extends Controller
*/
public function show(Request $request, Business $business, CrmTask $task)
{
$task->load(['assignee', 'creator', 'related']);
$task->load(['assignee', 'creator', 'contact', 'business', 'opportunity', 'order']);
return view('seller.crm.tasks.show', compact('business', 'task'));
}
@@ -108,11 +119,10 @@ class TaskController extends Controller
{
$validated = $request->validate([
'title' => 'sometimes|string|max:255',
'description' => 'nullable|string',
'type' => 'sometimes|in:call,email,meeting,task,follow_up',
'priority' => 'sometimes|in:low,medium,high,urgent',
'status' => 'sometimes|in:pending,in_progress,completed,cancelled',
'due_date' => 'sometimes|date',
'details' => 'nullable|string',
'type' => 'sometimes|in:call,email,meeting,follow_up,demo,other',
'priority' => 'sometimes|in:low,normal,high,urgent',
'due_at' => 'sometimes|date',
'assigned_to' => 'nullable|exists:users,id',
]);
@@ -140,10 +150,7 @@ class TaskController extends Controller
*/
public function complete(Request $request, Business $business, CrmTask $task)
{
$task->update([
'status' => 'completed',
'completed_at' => now(),
]);
$task->markComplete($request->user());
return redirect()
->back()

View File

@@ -3,11 +3,11 @@
namespace App\Models\Buyer;
use App\Models\Business;
use App\Models\Crm\CrmInvoice;
use App\Models\Order;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\Crm\Entities\CrmInvoice;
class BuyerInvoiceRecord extends Model
{

View File

@@ -3,10 +3,10 @@
namespace App\Models\Buyer;
use App\Models\Business;
use App\Models\Crm\CrmQuote;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\Crm\Entities\CrmQuote;
class BuyerQuoteApproval extends Model
{

View File

@@ -315,7 +315,7 @@ PROMPT;
public function recommendProducts(int $businessId, int $accountId): array
{
// Get account's purchase history and interests
$interests = \Modules\Crm\Entities\CrmProductInterest::forBusiness($businessId)
$interests = \App\Models\Crm\CrmProductInterest::forBusiness($businessId)
->forAccount($accountId)
->highInterest(20)
->with('product.category')

View File

@@ -474,7 +474,7 @@ class CrmAutomationService
protected function findLeastLoadedAgent(int $businessId): ?int
{
return \Modules\Crm\Entities\CrmTeamRole::forBusiness($businessId)
return \App\Models\Crm\CrmTeamRole::forBusiness($businessId)
->assignable()
->get()
->filter(fn ($role) => $role->canTakeMoreThreads())

View File

@@ -47,7 +47,7 @@ class PermissionService
return false;
}
$userPermissions = $businessUser->pivot->permissions ?? [];
$userPermissions = $this->normalizePermissions($businessUser->pivot->permissions);
// Check permission (supports wildcards)
return $this->hasPermissionInList($permission, $userPermissions);
@@ -102,7 +102,7 @@ class PermissionService
return false;
}
$currentPermissions = $businessUser->pivot->permissions ?? [];
$currentPermissions = $this->normalizePermissions($businessUser->pivot->permissions);
$newPermissions = array_unique(array_merge($currentPermissions, $permissions));
// Update permissions
@@ -147,7 +147,7 @@ class PermissionService
return false;
}
$currentPermissions = $businessUser->pivot->permissions ?? [];
$currentPermissions = $this->normalizePermissions($businessUser->pivot->permissions);
$newPermissions = array_diff($currentPermissions, $permissions);
// Update permissions
@@ -197,7 +197,7 @@ class PermissionService
return false;
}
$currentPermissions = $businessUser->pivot->permissions ?? [];
$currentPermissions = $this->normalizePermissions($businessUser->pivot->permissions);
$currentRoleTemplate = $businessUser->pivot->role_template;
// Update permissions and role template
@@ -261,7 +261,7 @@ class PermissionService
if ($merge) {
// Merge with existing permissions
$businessUser = $user->businesses()->where('businesses.id', $business->id)->first();
$currentPermissions = $businessUser?->pivot->permissions ?? [];
$currentPermissions = $this->normalizePermissions($businessUser?->pivot->permissions);
$finalPermissions = array_unique(array_merge($currentPermissions, $expandedPermissions));
} else {
// Replace existing permissions
@@ -425,6 +425,22 @@ class PermissionService
Cache::forget("permissions.{$userId}.{$businessId}");
}
/**
* Normalize permissions to array (handles JSON string from database)
*/
protected function normalizePermissions(mixed $permissions): array
{
if (is_array($permissions)) {
return $permissions;
}
if (is_string($permissions)) {
return json_decode($permissions, true) ?? [];
}
return [];
}
/**
* Get user's permissions with caching
*/
@@ -443,7 +459,7 @@ class PermissionService
->where('businesses.id', $business->id)
->first();
return $businessUser?->pivot->permissions ?? [];
return $this->normalizePermissions($businessUser?->pivot->permissions);
}
);
}

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* CRM Pipelines - Configurable deal stages for CRM Premium
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('crm_pipelines')) {
return;
}
Schema::create('crm_pipelines', function (Blueprint $table) {
$table->id();
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->json('stages')->nullable(); // Array of stage objects with name, probability, color
$table->boolean('is_default')->default(false);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
$table->index(['business_id', 'is_active']);
$table->index(['business_id', 'is_default']);
});
}
public function down(): void
{
Schema::dropIfExists('crm_pipelines');
}
};

View File

@@ -0,0 +1,155 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* CRM Calendar Integration Tables - Premium calendar sync features
*/
return new class extends Migration
{
public function up(): void
{
// Calendar connections (OAuth tokens for Google/Outlook)
if (! Schema::hasTable('crm_calendar_connections')) {
Schema::create('crm_calendar_connections', function (Blueprint $table) {
$table->id();
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('provider'); // google, outlook
$table->string('provider_user_id')->nullable();
$table->string('email')->nullable();
$table->text('access_token')->nullable();
$table->text('refresh_token')->nullable();
$table->timestamp('token_expires_at')->nullable();
$table->json('scopes')->nullable();
$table->boolean('is_active')->default(true);
$table->boolean('sync_enabled')->default(true);
$table->string('sync_direction')->default('both'); // inbound, outbound, both
$table->string('default_calendar_id')->nullable();
$table->timestamp('last_sync_at')->nullable();
$table->string('sync_status')->default('idle'); // idle, syncing, error
$table->text('sync_error')->nullable();
$table->text('sync_token')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['business_id', 'user_id', 'provider']);
$table->index(['business_id', 'is_active']);
});
}
// Synced events from external calendars
if (! Schema::hasTable('crm_synced_events')) {
Schema::create('crm_synced_events', function (Blueprint $table) {
$table->id();
$table->foreignId('calendar_connection_id')->constrained('crm_calendar_connections')->cascadeOnDelete();
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('calendar_event_id')->nullable()->constrained('calendar_events')->nullOnDelete();
$table->string('external_id');
$table->string('external_calendar_id')->nullable();
$table->string('external_link')->nullable();
$table->string('title');
$table->text('description')->nullable();
$table->string('location')->nullable();
$table->timestamp('start_at');
$table->timestamp('end_at')->nullable();
$table->boolean('all_day')->default(false);
$table->string('timezone')->nullable();
$table->boolean('is_recurring')->default(false);
$table->string('recurrence_rule')->nullable();
$table->string('recurring_event_id')->nullable();
$table->string('status')->default('confirmed'); // confirmed, tentative, cancelled
$table->json('attendees')->nullable();
$table->string('etag')->nullable();
$table->timestamp('external_updated_at')->nullable();
$table->timestamp('synced_at')->nullable();
$table->timestamps();
$table->unique(['calendar_connection_id', 'external_id']);
$table->index(['user_id', 'start_at']);
$table->index(['business_id', 'start_at']);
});
}
// Meeting links (Calendly-style scheduling)
if (! Schema::hasTable('crm_meeting_links')) {
Schema::create('crm_meeting_links', function (Blueprint $table) {
$table->id();
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->json('duration_options')->nullable(); // [15, 30, 60]
$table->integer('default_duration')->default(30);
$table->json('availability')->nullable(); // Weekly schedule
$table->string('timezone')->default('America/Phoenix');
$table->integer('buffer_before')->default(0); // minutes
$table->integer('buffer_after')->default(0);
$table->integer('min_notice')->default(60); // minutes
$table->integer('max_days_ahead')->default(60);
$table->foreignId('calendar_connection_id')->nullable()->constrained('crm_calendar_connections')->nullOnDelete();
$table->string('location_type')->default('google_meet'); // google_meet, zoom, phone, in_person, custom
$table->string('custom_location')->nullable();
$table->string('zoom_meeting_id')->nullable();
$table->json('questions')->nullable(); // Custom form questions
$table->text('confirmation_message')->nullable();
$table->boolean('requires_confirmation')->default(false);
$table->unsignedInteger('bookings_count')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
$table->index(['business_id', 'is_active']);
$table->index(['user_id', 'is_active']);
});
}
// Meeting bookings
if (! Schema::hasTable('crm_meeting_bookings')) {
Schema::create('crm_meeting_bookings', function (Blueprint $table) {
$table->id();
$table->foreignId('meeting_link_id')->constrained('crm_meeting_links')->cascadeOnDelete();
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); // Host
$table->string('booker_name');
$table->string('booker_email');
$table->string('booker_phone')->nullable();
$table->foreignId('contact_id')->nullable()->constrained('contacts')->nullOnDelete();
$table->foreignId('account_id')->nullable()->constrained('businesses')->nullOnDelete();
$table->timestamp('start_at');
$table->timestamp('end_at');
$table->integer('duration'); // minutes
$table->string('timezone')->nullable();
$table->string('location_type')->nullable();
$table->string('location')->nullable();
$table->string('meeting_id')->nullable(); // External meeting ID (Zoom, etc.)
$table->json('answers')->nullable(); // Answers to custom questions
$table->string('status')->default('scheduled'); // scheduled, confirmed, cancelled, completed, no_show
$table->foreignId('calendar_event_id')->nullable()->constrained('calendar_events')->nullOnDelete();
$table->string('external_event_id')->nullable();
$table->boolean('reminder_sent')->default(false);
$table->timestamp('reminder_sent_at')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->string('cancelled_by')->nullable(); // 'host' or 'booker' or email
$table->text('cancellation_reason')->nullable();
$table->timestamps();
$table->index(['meeting_link_id', 'status']);
$table->index(['user_id', 'start_at']);
$table->index(['business_id', 'start_at']);
});
}
}
public function down(): void
{
Schema::dropIfExists('crm_meeting_bookings');
Schema::dropIfExists('crm_meeting_links');
Schema::dropIfExists('crm_synced_events');
Schema::dropIfExists('crm_calendar_connections');
}
};

View File

@@ -9,7 +9,7 @@
sortBy: 'name',
brandSegment: 'all',
selectedBrand: null,
brands: @js($brands->map(function($brand) use ($business) {
brands: @js($brands->filter(fn($brand) => $brand->hashid)->map(function($brand) use ($business) {
// Enhanced with relationship + performance data
$isHouseBrand = rand(0, 100) > 60;
$tier = $isHouseBrand ? 'house' : (['premium', 'partner', 'white-label'][rand(0, 2)]);

View File

@@ -282,7 +282,7 @@
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
@forelse($brandOverview as $brand)
@forelse($brandOverview->filter(fn($b) => $b['brand']?->hashid) as $brand)
<div class="card bg-base-100 border border-base-200 shadow-sm">
<div class="card-body p-5">
{{-- Brand Header --}}
@@ -406,7 +406,7 @@
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@forelse($myBrandPerformance as $brandPerf)
@forelse($myBrandPerformance->filter(fn($b) => $b['brand']?->hashid) as $brandPerf)
<div class="card bg-base-100 border border-base-200 shadow-sm">
<div class="card-body p-4">
<div class="flex items-start gap-3 mb-3">