Files
hub/docs/conversations.md
kelly ce5c670bf2 feat: Add AI Copilot module with multi-provider support + brand-scoped campaigns + comprehensive docs
## 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
2025-11-23 12:31:19 -07:00

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 key
  • brand_id - Owner brand (required)
  • buyer_business_id - Buyer business (required)
  • contact_id - Specific contact within buyer business (nullable)
  • subject - Conversation subject line
  • status - open or closed
  • last_message_at - Timestamp of most recent message
  • unread_count - Count of unread messages for brand
  • created_at, updated_at

messages Table

See messaging.md for complete schema. Key fields for conversations:

  • conversation_id - Parent conversation (required)
  • direction - inbound or outbound
  • channel - email or sms
  • from, to, subject, body
  • delivery_status - Tracking state

conversation_participants Table

  • conversation_id
  • participant_type - Polymorphic: App\\Models\\Business or App\\Models\\Contact
  • participant_id
  • joined_at
  • left_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:

  1. Email Threading Check: Look for existing conversation using In-Reply-To or References headers
  2. SMS Threading Check: Find recent open conversation between brand and contact (within 7 days)
  3. If No Match: Create new conversation with:
    • brand_id = recipient brand
    • buyer_business_id = sender's business
    • contact_id = sender contact
    • subject = Email subject or "SMS from {contact name}"
    • status = open
    • last_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:

  1. Check for existing open conversation between brand and buyer
  2. If none found, create conversation with user-entered subject
  3. 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 From field
  • Identify brand from To field (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 email
  • To → Brand email address
  • Subject → Used for threading or new conversation subject
  • Message-ID → Store in message_id column
  • In-Reply-To → Used to find parent conversation
  • References → Email threading chain
  • Body → HTML or plain text content

SMS:

  • From → Contact phone number
  • To → Brand phone number (from Twilio webhook)
  • Body → Plain text message content
  • MessageSid → Store in message_id column

Outbound Messages (direction = 'outbound')

Source: Brand user composing reply

Processing:

  • Brand user types message in conversation view
  • System sets From to brand's email/phone
  • System sets To to buyer contact's email/phone
  • For email: Set In-Reply-To and References headers 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:

  1. Contact Info: Buyer business name, contact name
  2. Subject: Conversation subject line (truncated)
  3. Preview: First 100 chars of most recent message
  4. Timestamp: Last message time (relative)
  5. Unread Badge: If unread messages exist
  6. Channel Icon: Email or SMS indicator
  7. 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:

  1. Status Dropdown: All / Open / Closed
  2. Unread Toggle: Show only unread conversations
  3. Channel Dropdown: All / Email / SMS
  4. 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:

  1. Every outbound email includes Message-ID header
  2. Replies include In-Reply-To (parent message ID) and References (full thread)
  3. When inbound email arrives, check In-Reply-To to find parent message
  4. If parent found, attach to same conversation
  5. If not found, check References for any message ID in current conversation
  6. 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:

  1. Find most recent open conversation between brand phone and contact phone
  2. If found and recent (within 7 days), append to that conversation
  3. 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:

  1. Conversation subject
  2. Participant names (business name, contact name)
  3. Participant emails/phones
  4. 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:

  1. Find or Create Conversation: Get existing conversation or create new with subject = campaign name
  2. Create Message: Attach campaign message to conversation
  3. Direction: Always outbound
  4. Delivery Tracking: Webhooks update delivery status
  5. 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