## AI Copilot Module System
- Add copilot_enabled flag to businesses table
- Add Copilot Module toggle in Filament admin (Premium Features)
- Gate all Copilot buttons with copilot_enabled check
- Enable by default for Cannabrands only
## AI Settings Multi-Provider Support
- Support 5 AI providers: Anthropic, OpenAI, Perplexity, Canva, Jasper
- Separate encrypted API keys per provider
- Model dropdowns populated from config/ai.php
- Test Connection feature with real API validation
- Existing Connections summary shows status for all providers
- Provider-specific settings only shown when provider selected
## Brand-Scoped Campaign System
- Add brand_id to broadcasts table
- Brand selector component (reusable across forms)
- Campaign create/edit requires brand selection
- All campaigns now scoped to specific brand
## Documentation (9 New Files)
- docs/modules.md - Module system architecture
- docs/messaging.md - Messaging and conversation system
- docs/copilot.md - AI Copilot features and configuration
- docs/segmentation.md - Buyer segmentation system
- docs/sendportal.md - SendPortal multi-brand integration
- docs/campaigns.md - Campaign creation and delivery flow
- docs/conversations.md - Conversation lifecycle and threading
- docs/brand-settings.md - Brand configuration and voice system
- docs/architecture.md - High-level system overview
## Bug Fixes
- Remove duplicate FailedJobResource (Horizon already provides this at /horizon/failed)
- Fix missing Action import in Filament resources
- Update brand policy for proper access control
## Database Migrations
- 2025_11_23_175211_add_copilot_enabled_to_businesses_table.php
- 2025_11_23_180000_add_brand_id_to_broadcasts_table.php
- 2025_11_23_180326_update_ai_settings_for_multiple_providers.php
- 2025_11_23_184331_add_new_ai_providers_to_ai_settings.php
## Notes
- Pint: ✅ Passed (all code style checks)
- Tests: Failing due to pre-existing database schema dump conflicts (not related to these changes)
- Schema issue needs separate fix: pgsql-schema.sql contains tables that migrations also create
15 KiB
Conversations System
Overview
Conversations provide unified threading for all brand-buyer communications across email and SMS channels. Each conversation has a single subject, multiple participants, and chronologically ordered messages.
Database Structure
conversations Table
id- Primary keybrand_id- Owner brand (required)buyer_business_id- Buyer business (required)contact_id- Specific contact within buyer business (nullable)subject- Conversation subject linestatus-openorclosedlast_message_at- Timestamp of most recent messageunread_count- Count of unread messages for brandcreated_at,updated_at
messages Table
See messaging.md for complete schema. Key fields for conversations:
conversation_id- Parent conversation (required)direction-inboundoroutboundchannel-emailorsmsfrom,to,subject,bodydelivery_status- Tracking state
conversation_participants Table
conversation_idparticipant_type- Polymorphic:App\\Models\\BusinessorApp\\Models\\Contactparticipant_idjoined_atleft_at- Nullable, for future group conversation support
Conversation Lifecycle
1. Conversation Creation
Conversations are created in two ways:
A. Inbound Message Creates Conversation
When inbound email/SMS arrives:
- Email Threading Check: Look for existing conversation using
In-Reply-ToorReferencesheaders - SMS Threading Check: Find recent open conversation between brand and contact (within 7 days)
- If No Match: Create new conversation with:
brand_id= recipient brandbuyer_business_id= sender's businesscontact_id= sender contactsubject= Email subject or "SMS from {contact name}"status=openlast_message_at= now()
// Example inbound email processing
$conversation = Conversation::firstOrCreate([
'brand_id' => $brand->id,
'buyer_business_id' => $contact->business_id,
'subject' => $email->subject,
], [
'contact_id' => $contact->id,
'status' => 'open',
'last_message_at' => now(),
]);
B. Outbound Message Creates Conversation
When brand sends first message to buyer:
- Check for existing open conversation between brand and buyer
- If none found, create conversation with user-entered subject
- Attach first message to conversation
2. Message Attachment
Every message MUST attach to a conversation:
$conversation->messages()->create([
'brand_id' => $brand->id,
'direction' => 'outbound',
'channel' => 'email',
'from' => $brand->email,
'to' => $buyer->email,
'subject' => $conversation->subject,
'body' => $messageBody,
'delivery_status' => 'pending',
]);
// Update conversation timestamp
$conversation->update(['last_message_at' => now()]);
3. Message Direction
Inbound Messages (direction = 'inbound')
Source: External system (email server, SMS webhook)
Processing:
- Parse sender from
Fromfield - Identify brand from
Tofield (recipient email/phone) - Find or create contact record
- Thread to conversation using email headers or recent SMS history
- Mark conversation as unread for brand
Metadata Parsing:
Email:
From→ Contact emailTo→ Brand email addressSubject→ Used for threading or new conversation subjectMessage-ID→ Store inmessage_idcolumnIn-Reply-To→ Used to find parent conversationReferences→ Email threading chainBody→ HTML or plain text content
SMS:
From→ Contact phone numberTo→ Brand phone number (from Twilio webhook)Body→ Plain text message contentMessageSid→ Store inmessage_idcolumn
Outbound Messages (direction = 'outbound')
Source: Brand user composing reply
Processing:
- Brand user types message in conversation view
- System sets
Fromto brand's email/phone - System sets
Toto buyer contact's email/phone - For email: Set
In-Reply-ToandReferencesheaders for threading - Send via configured provider (SendPortal, Twilio, etc.)
- Create message record with
delivery_status = 'pending' - Provider webhooks update delivery status
Metadata Generation:
Email:
$messageId = '<' . Str::uuid() . '@' . config('app.domain') . '>';
$message = Message::create([
'message_id' => $messageId,
'in_reply_to' => $conversation->messages()->where('direction', 'inbound')->latest()->value('message_id'),
'references' => $conversation->messages()->pluck('message_id')->implode(' '),
// ... other fields
]);
SMS:
- No threading metadata needed
- Twilio tracks conversation by phone number pair
4. Conversation Status
Open Status
- Default for all new conversations
- Appears in default conversation list
- Triggers notifications for new messages
- Can be filtered:
/s/{business}/messaging?status=open
Closed Status
- Manually closed by brand user
- Hides from default list (requires filter to view)
- No notifications for new messages
- Can be reopened at any time
- Useful for resolved support tickets or completed sales conversations
Actions:
- Close:
POST /s/{business}/messaging/{conversation}/close - Reopen:
POST /s/{business}/messaging/{conversation}/reopen
Controller Logic:
public function close(Conversation $conversation)
{
$this->authorize('update', $conversation);
$conversation->update(['status' => 'closed']);
return back()->with('success', 'Conversation closed.');
}
Timeline Display
Message Ordering
Messages displayed in chronological order (oldest to newest):
@foreach($conversation->messages()->orderBy('created_at')->get() as $message)
<div class="message {{ $message->direction }}">
{{-- Message content --}}
</div>
@endforeach
Display Rules
Inbound Messages
Visual Treatment:
- Left-aligned in timeline
- Gray background
- Sender name + email/phone shown
- Channel icon (envelope or phone)
- Timestamp (relative: "2 hours ago")
Example:
<div class="flex justify-start">
<div class="max-w-xl bg-gray-100 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="icon-[heroicons--envelope] size-4"></span>
<span class="font-medium">John Doe</span>
<span class="text-sm text-gray-500">john@dispensary.com</span>
</div>
<div class="prose">
{!! $message->body !!}
</div>
<div class="text-xs text-gray-400 mt-2">
{{ $message->created_at->diffForHumans() }}
</div>
</div>
</div>
Outbound Messages
Visual Treatment:
- Right-aligned in timeline
- Brand color background
- Sender name (brand user who sent)
- Delivery status badge
- Channel icon
- Timestamp
Delivery Status Badges:
pending→ Gray "Queued"sent→ Blue "Sent"delivered→ Green "Delivered"failed→ Red "Failed"bounced→ Red "Bounced"opened→ Green "Opened" (email only)clicked→ Green "Clicked" (email only)
Example:
<div class="flex justify-end">
<div class="max-w-xl bg-primary text-white rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="icon-[heroicons--paper-airplane] size-4"></span>
<span class="font-medium">{{ $message->sender->name }}</span>
<span class="badge badge-sm {{ $message->delivery_status_color }}">
{{ ucfirst($message->delivery_status) }}
</span>
</div>
<div class="prose prose-invert">
{!! $message->body !!}
</div>
<div class="text-xs opacity-75 mt-2">
{{ $message->created_at->diffForHumans() }}
</div>
</div>
</div>
System Messages
Future Enhancement: Display system events in timeline:
- "Conversation closed by Kelly"
- "Campaign message sent"
- "Order #1234 created"
Styling: Center-aligned, italic, gray text
Conversation List View
Route
GET /s/{business}/messaging or /s/{business}/messaging/index
Display Format
Each conversation shown as card/row with:
- Contact Info: Buyer business name, contact name
- Subject: Conversation subject line (truncated)
- Preview: First 100 chars of most recent message
- Timestamp: Last message time (relative)
- Unread Badge: If unread messages exist
- Channel Icon: Email or SMS indicator
- Status Indicator: Open (default) or Closed (if filtered)
Example Row:
[Email Icon] Green Valley Dispensary · John Doe
Re: Product inquiry - Blue Dream availability
"Hi, we're interested in ordering 10 units of..."
[Unread: 2] 2 hours ago
Filters
Available filters at top of list:
- Status Dropdown: All / Open / Closed
- Unread Toggle: Show only unread conversations
- Channel Dropdown: All / Email / SMS
- Search Bar: Full-text search across subject, participant names, message body
URL Examples:
/s/cannabrands/messaging?status=open/s/cannabrands/messaging?unread=1/s/cannabrands/messaging?channel=email/s/cannabrands/messaging?search=blue+dream
Sorting
Default: Most recent message first (last_message_at DESC)
Optional future sorts:
- Oldest first
- Unread first
- By contact name
Conversation Detail View
Route
GET /s/{business}/messaging/{conversation}/show
Layout
Top Bar:
- Contact info (name, email/phone, business)
- Subject line (editable?)
- Status toggle (Open/Closed buttons)
- Close conversation button
Timeline:
- Scrollable message list
- Auto-scroll to bottom on load
- Load more (pagination) at top
Compose Bar (bottom):
- Channel selector (Email/SMS if contact has both)
- Message textarea
- Send button
- Attachments (future)
- AI Copilot buttons (if enabled)
Threading Logic
Email Threading
How It Works:
- Every outbound email includes
Message-IDheader - Replies include
In-Reply-To(parent message ID) andReferences(full thread) - When inbound email arrives, check
In-Reply-Toto find parent message - If parent found, attach to same conversation
- If not found, check
Referencesfor any message ID in current conversation - If still not found, create new conversation
Implementation:
$inReplyTo = $email->getHeaderValue('In-Reply-To');
$references = $email->getHeaderValue('References');
$parentMessage = Message::where('message_id', $inReplyTo)->first();
if ($parentMessage) {
$conversation = $parentMessage->conversation;
} else {
// Try references
$referenceIds = explode(' ', $references);
$parentMessage = Message::whereIn('message_id', $referenceIds)->first();
$conversation = $parentMessage?->conversation;
}
if (!$conversation) {
// Create new conversation
}
SMS Threading
How It Works:
- Find most recent open conversation between brand phone and contact phone
- If found and recent (within 7 days), append to that conversation
- Otherwise, create new conversation
Why 7 Days?: SMS conversations are typically short-lived. After a week, new topic = new conversation.
Implementation:
$conversation = Conversation::where('brand_id', $brand->id)
->where('buyer_business_id', $contact->business_id)
->where('status', 'open')
->whereHas('messages', function ($q) use ($brandPhone, $contactPhone) {
$q->where('channel', 'sms')
->where(function ($q) use ($brandPhone, $contactPhone) {
$q->where('from', $brandPhone)->where('to', $contactPhone)
->orWhere('from', $contactPhone)->where('to', $brandPhone);
});
})
->where('last_message_at', '>=', now()->subDays(7))
->first();
Search Implementation
Full-text search across:
- Conversation subject
- Participant names (business name, contact name)
- Participant emails/phones
- Message body content
Query Example:
$query = Conversation::where('brand_id', $business->id)
->when($search, function ($q) use ($search) {
$q->where(function ($q) use ($search) {
$q->where('subject', 'ILIKE', "%{$search}%")
->orWhereHas('buyerBusiness', fn ($q) => $q->where('name', 'ILIKE', "%{$search}%"))
->orWhereHas('contact', fn ($q) => $q->where('name', 'ILIKE', "%{$search}%"))
->orWhereHas('messages', fn ($q) => $q->where('body', 'ILIKE', "%{$search}%"));
});
});
Note: For production, use PostgreSQL full-text search or Meilisearch for better performance.
Unread Tracking
Marking as Read
When brand user opens conversation detail view:
public function show(Conversation $conversation)
{
$this->authorize('view', $conversation);
// Mark all messages as read
$conversation->messages()
->where('direction', 'inbound')
->whereNull('read_at')
->update(['read_at' => now()]);
// Update unread count
$conversation->update([
'unread_count' => 0,
]);
return view('seller.messaging.show', compact('conversation'));
}
Unread Badge
Display unread count in conversation list:
@if($conversation->unread_count > 0)
<span class="badge badge-primary badge-sm">
{{ $conversation->unread_count }}
</span>
@endif
Campaign Messages in Conversations
When a campaign sends to buyers, each message creates/updates a conversation:
- Find or Create Conversation: Get existing conversation or create new with subject = campaign name
- Create Message: Attach campaign message to conversation
- Direction: Always
outbound - Delivery Tracking: Webhooks update delivery status
- Replies: If buyer replies to campaign email, it threads back to conversation
Benefits:
- All brand-buyer communications in one place
- Campaign responses visible in conversation timeline
- Seamless transition from campaign to 1-on-1 conversation
See Also: campaigns.md (section 3: Campaign Sending)
Permissions
Conversation Access
Brands can only view conversations where brand_id matches their business.
Policy:
public function view(User $user, Conversation $conversation): bool
{
return $user->business_id === $conversation->brand_id;
}
Buyer Access (Future)
Buyers could view their conversations with brands at /b/{business}/messages.
Scope: Only conversations where buyer_business_id matches buyer's business.
Future Enhancements
- Attachments: File uploads in messages
- Group Conversations: Multiple contacts in one conversation
- Internal Notes: Brand-only notes on conversations
- Canned Responses: Quick reply templates
- Assignment: Assign conversations to specific team members
- SLAs: Track response time metrics
- Sentiment Analysis: AI-powered conversation insights
- WhatsApp/Facebook: Additional messaging channels