Unified Inbox - Feature Documentation
Owner: Riley Chen Version: 2.3.1
| Owner | Last verified | Next verification due | Last CI run ID | Coverage % | Open risks |
|---|---|---|---|---|---|
| Riley Chen | 2025-12-05 | 2026-01-04 | ci.yml#2190 | Pending (inbox API + Cypress inbox specs) | External provider webhooks (FB/IG/Yelp) stubbed only; search relevance + SLA alerts not performance-tested |
Verification
- CI jobs:
ci.ymlAPI + Cypress (last run: ci.yml#2190) - Commands (demo portal only; do not target production clients):
pnpm test:api --config jest.config.api.cjs --runTestsByPath tests/app/api/inbox/conversations/route.test.ts tests/app/api/inbox/conversations/[conversationId]/route.test.ts tests/app/api/inbox/messages/route.test.tspnpm cypress:run --spec "cypress/e2e/inbox/inbox-interactions.cy.ts","cypress/e2e/inbox/inbox-search-filtering.cy.ts","cypress/e2e/inbox/inbox-error-handling.cy.ts"pnpm db:health
- Test suites + environment:
- Inbox APIs:
tests/app/api/inbox/conversations/route.test.ts,.../[conversationId]/route.test.ts,tests/app/api/inbox/messages/route.test.ts(CI seeded demo DB) - End-to-end:
cypress/e2e/inbox/inbox-interactions.cy.ts,inbox-search-filtering.cy.ts,inbox-error-handling.cy.ts(staging demo tenant; webhooks mocked)
- Inbox APIs:
- Next verification due: 2026-01-04
Table of Contents
- Problem / Job-to-Be-Done
- Solution Overview
- Scope
- Acceptance Criteria
- Dependencies & Risks
- Technical Architecture
- Data Model
- Real-Time Updates
- Provider Integrations
- Demo vs Production Mode
- API Reference
- Configuration
- Testing Strategy
- Performance
- Troubleshooting
- File Reference
- Version History
Problem / Job-to-Be-Done
User Pain Points
| Pain Point | Impact | Solution |
|---|---|---|
| Fragmented messaging | Context switching across platforms | Unified inbox aggregates Yelp, Facebook, SMS, email |
| Missed messages | Lost customers, poor reviews | Real-time WebSocket updates + notifications |
| Slow response times | Customer dissatisfaction | AI-powered suggestions + canned responses |
| No conversation context | Repeated questions, inefficiency | Full conversation history + contact details |
| Manual triage | Agent overload | Auto-prioritization based on urgency |
Business Impact
| Metric | Before Unified Inbox | After Unified Inbox |
|---|---|---|
| Average response time | 4+ hours | <30 minutes |
| Messages missed | 15-20% | <2% |
| Agent productivity | 50 messages/day | 120+ messages/day |
| Customer satisfaction | 3.2/5 | 4.7/5 |
| Platform switching | 15+ times/hour | Zero |
Job Stories
When I receive customer inquiries across Yelp, Facebook, and email, I want to see all messages in one centralized inbox, So that I can respond quickly without switching between platforms.
When a customer sends an urgent message, I want the system to prioritize it automatically, So that I can address critical issues first.
When I'm handling multiple conversations, I want AI-powered response suggestions, So that I can maintain quality while responding faster.
Solution Overview
User Journey
┌─────────────────────────────────────────────────────────────────────────────┐
│ UNIFIED INBOX FLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────┐ │
│ │ Customer │───▶│ Yelp/FB/SMS │───▶│ Webhook │ │
│ │ Sends Message│ │ Platform │ │ Received │ │
│ └──────────────┘ └───────────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Prisma DB │ │
│ │ Conversation │ │
│ │ + Message │ │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ WebSocket │ │
│ │ Broadcast │ │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────┐ │
│ │ Agent │◀───│ InboxProvider │◀───│ Real-time │ │
│ │ Sees Message │ │ UI Updates │ │ Update │ │
│ └──────────────┘ └───────────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────┐ │
│ │ Responds │───▶│ API │───▶│ Sent to │ │
│ │ to Customer │ │ /messages │ │ Platform │ │
│ └──────────────┘ └───────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Inbox Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ CLIENT (React Components) │
│ InboxProvider • useInbox hook • ConversationList • MessageDetail │
└─────────────────────────────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ WebSocket (Real-time) │ │ API Routes (REST) │
│ • Message updates │ │ /api/inbox/conversations │
│ • Typing indicators │ │ /api/inbox/messages │
│ • Presence │ │ /api/inbox/autoresponder │
│ • Read receipts │ │ /api/inbox/search │
└─────────────────────────────┘ └─────────────────────────────┘
│ │
└───────────────┬───────────────┘
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ SERVICE LAYER │
│ InboxServiceFactory • ConversationService • MessageService │
│ Auto-responder • AI Suggestions • Canned Responses │
└─────────────────────────────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ Database (Prisma) │ │ External Providers │
│ • Conversation │ │ • Yelp API │
│ • Message │ │ • Facebook Graph API │
│ • Contact │ │ • SMS Gateway │
│ • YelpConversation* │ │ • Email Provider │
│ • FacebookConversation* │ │ │
└─────────────────────────────┘ └─────────────────────────────┘
* Legacy tables - being migrated to unified model
Key Capabilities
| Capability | Implementation | Status |
|---|---|---|
| Multi-channel aggregation | Yelp, Facebook, SMS, email, webchat | ✅ Complete |
| Real-time updates | WebSocket with reconnection logic | ✅ Complete |
| Conversation threading | Message grouping by conversation ID | ✅ Complete |
| Contact management | Unified contact records | ✅ Complete |
| Auto-responder | Configurable rules, hours, delays | ✅ Complete |
| Canned responses | Quick reply templates | ✅ Complete |
| AI suggestions | Claude-powered response generation | ✅ Complete |
| Typing indicators | Real-time typing awareness | ✅ Complete |
| Read receipts | Message read tracking | ✅ Complete |
| Presence tracking | Online/offline/away status | ✅ Complete |
| Search & filtering | Full-text search, channel/status filters | ✅ Complete |
| Priority routing | Urgent messages highlighted | ✅ Complete |
Scope
In Scope (v2.2.0)
- Unified conversation list across all channels
- Real-time message delivery via WebSocket
- Yelp message integration (conversations + messages)
- Facebook Messenger integration (conversations + messages)
- Generic message channels (SMS, email, webchat)
- Conversation filtering (status, channel, priority)
- Full-text search across conversations
- Auto-responder with business hours
- Canned response library
- AI-powered response suggestions (Claude API)
- Typing indicators and read receipts
- User presence tracking
- Demo mode with simulated conversations
- Production mode with real data
- Strict portal isolation (multi-tenancy)
- Contact information sidebar
- Conversation history view
- Message pagination
Out of Scope (Future Versions)
- Instagram Direct Message integration
- Twitter DM integration
- WhatsApp Business integration
- Video message support
- Rich media attachments (images, videos, files)
- Message scheduling
- Team collaboration (assignments, notes, internal comments)
- SLA tracking and alerts
- Sentiment analysis dashboard
- Bulk actions (mass archive, mass respond)
- Email thread parsing
- SMS short code support
Acceptance Criteria
Conversation Management
Feature: Unified Conversation View
As an agent
I want to see all customer conversations in one place
So that I can respond efficiently
Scenario: View all conversations
GIVEN I am logged into the inbox
WHEN I navigate to the conversations page
THEN I should see conversations from all channels (Yelp, Facebook, SMS, email)
AND conversations should be sorted by most recent activity
AND each conversation should show last message preview
AND unread count should be visible
Scenario: Filter conversations by channel
GIVEN I am viewing the conversation list
WHEN I select "Yelp" from the channel filter
THEN I should only see Yelp conversations
AND the conversation count should update
Scenario: Search conversations
GIVEN I am viewing the conversation list
WHEN I type "refund request" in the search box
THEN conversations containing "refund request" should appear
AND search should match both message content and contact names
Real-Time Messaging
Feature: Real-Time Message Delivery
As an agent
I want to receive messages instantly
So that I can respond to customers quickly
Scenario: Receive new message
GIVEN I am viewing the inbox
AND WebSocket connection is established
WHEN a customer sends a message via Yelp
THEN the message should appear within 1 second
AND unread count should increment
AND notification sound should play
AND browser notification should appear (if permitted)
Scenario: Send message
GIVEN I am viewing a conversation
WHEN I type a reply and click Send
THEN the message should be sent immediately
AND it should appear in the conversation thread
AND the customer should receive it on their platform
Auto-Responder
Feature: Automated Responses
As a business owner
I want to automatically respond to messages outside business hours
So that customers know when to expect a reply
Scenario: Auto-respond during off-hours
GIVEN autoresponder is enabled
AND business hours are 9 AM - 5 PM
AND current time is 8 PM
WHEN a customer sends a message
THEN an auto-response should be sent after the configured delay
AND the message should include business hours information
Scenario: No auto-response during business hours
GIVEN autoresponder is enabled with businessHoursOnly=true
AND current time is 2 PM (business hours)
WHEN a customer sends a message
THEN no auto-response should be sent
Dependencies & Risks
External Dependencies
| Dependency | Purpose | Fallback | Risk Level |
|---|---|---|---|
| Yelp Messaging API | Receive/send Yelp messages | Manual polling | 🟡 Medium |
| Facebook Graph API | Receive/send Facebook messages | Webhook buffer | 🟡 Medium |
| WebSocket Server | Real-time updates | REST polling fallback | 🔴 High |
| Prisma Database | Conversation persistence | None (core dependency) | 🔴 Critical |
| Claude API | AI response suggestions | Canned responses only | 🟢 Low |
Internal Dependencies
| Dependency | Purpose | Owner |
|---|---|---|
/lib/providers/InboxContext.tsx | State management | Inbox Team |
/lib/services/conversation-service.ts | Conversation CRUD | Backend Team |
/lib/hooks/useWebSocket.ts | WebSocket connection | Core Team |
/lib/services/ai/mock-ai-service.ts | AI suggestions | AI Team |
/app/api/inbox/* | REST API endpoints | API Team |
Risks & Mitigations
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| WebSocket connection drops | Medium | High | Auto-reconnect with exponential backoff, REST fallback |
| Message delivery failure | Low | Critical | Retry logic with DLQ, manual sync option |
| High message volume | Medium | Medium | Pagination, lazy loading, virtual scrolling |
| Provider API rate limits | Medium | Medium | Request throttling, caching, webhook priority |
| Cross-portal data leakage | Low | Critical | Strict portal ID checks, row-level security |
| AI suggestion latency | Medium | Low | Async loading, cached suggestions, timeout handling |
Technical Architecture
Component Hierarchy
InboxProvider (Context)
├── InboxPage (Main container)
│ ├── ConversationList
│ │ ├── Conversation (item)
│ │ └── Conversation filters
│ ├── MessageDetail
│ │ ├── Messages (embedded in conversation)
│ │ ├── MessageInput
│ │ └── TypingIndicator
│ ├── ContactInfoSidebar
│ ├── CannedResponseEditor
│ ├── AutoresponderSettings
│ └── InboxOnboarding (for new users)
└── WebSocketConnection (Real-time layer)
InboxProvider State Management
interface InboxState {
// Data
activeConversations: Conversation[];
archivedConversations: Conversation[];
contacts: Contact[];
messages: Message[];
selectedConversation: Conversation | null;
selectedMessage: Message | null;
// UI State
searchTerm: string;
selectedChannel: Channel | null;
showReadMessages: boolean;
starredFilter: boolean;
isLoading: boolean;
error: string | null;
// Real-time State
activeUsers: Record<string, UserPresence>;
typingUsers: Record<string, TypingIndicator>;
readReceipts: Record<string, ReadReceipt>;
// Settings
autoresponderSettings: AutoresponderSettings;
cannedResponses: CannedResponse[];
// AI Features
responseSuggestions: AIResponseSuggestion[];
generatingSuggestions: boolean;
// Mode
demoMode: boolean;
}
Service Architecture
// Service Factory Pattern - Handles demo/production switching
class InboxServiceFactory {
private mockService: InboxServiceInterface;
private realService: InboxServiceInterface;
getServiceForPortal(portalId: string): InboxServiceInterface {
return isDemoPortal(portalId) ? this.mockService : this.realService;
}
}
// Interface all services implement
interface InboxServiceInterface {
getActiveConversations(): Promise<Conversation[]>;
getMessages(locationId: string): Promise<Message[]>;
sendMessage(content: string, channel: Channel): Promise<Message>;
markAsRead(messageId: string): Promise<void>;
getContacts(): Promise<Contact[]>;
}
Data Model
Core Entities
Conversation
interface Conversation {
id: string; // Primary key
title: string; // Display title
portalId: string; // Portal FK (multi-tenancy)
channel: Channel; // 'yelp' | 'facebook' | 'sms' | 'email' | 'webchat' | 'voice'
status: ConversationStatus; // 'active' | 'archived' | 'pending' | 'completed'
priority: ConversationPriority;// 'low' | 'medium' | 'high' | 'urgent'
unreadCount: number; // Count of unread messages
lastMessagePreview: string; // Preview text
lastMessageAt: Date; // Sort key
externalId?: string; // Yelp/Facebook conversation ID
createdAt: Date;
updatedAt: Date;
// Relations
Messages: Message[];
Participants: ConversationParticipant[];
}
Message
interface Message {
id: string; // Primary key
conversationId: string; // Conversation FK
contactId: string; // Contact FK
portalId: string; // Portal FK
content: string; // Message text
channel: Channel;
direction: 'incoming' | 'outgoing';
status: 'sent' | 'delivered' | 'read' | 'failed' | 'archived';
isRead: boolean;
isAutomated: boolean;
senderName: string;
from: string; // Phone/email/ID
sentAt: Date;
readAt?: Date;
externalId?: string; // Provider message ID
createdAt: Date;
updatedAt: Date;
// AI Analysis (optional)
sentiment?: 'positive' | 'negative' | 'neutral' | 'mixed';
priority?: 'low' | 'normal' | 'high' | 'urgent';
aiConfidence?: number;
}
Contact
interface Contact {
id: string; // Primary key
name: string;
email?: string;
phone?: string;
clientId: string; // Client FK
avatarUrl?: string;
channel?: Channel; // Preferred channel
lastActive?: Date;
createdAt: Date;
updatedAt: Date;
// Relations
Messages: Message[];
Conversations: Conversation[];
}
Database Schema
model Conversation {
id String @id @default(cuid())
title String
portalId String
channel String
status ConversationStatus @default(active)
priority ConversationPriority @default(medium)
unreadCount Int @default(0)
lastMessagePreview String?
lastMessageAt DateTime?
externalId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Portal Portal @relation(fields: [portalId], references: [id])
Messages Message[]
Participants ConversationParticipant[]
@@index([portalId, lastMessageAt])
@@index([portalId, status])
@@index([channel, status])
}
model Message {
id String @id @default(cuid())
conversationId String
contactId String
portalId String
content String @db.Text
channel String
direction String // 'incoming' | 'outgoing'
status String @default("sent")
isRead Boolean @default(false)
isAutomated Boolean @default(false)
senderName String
from String
sentAt DateTime @default(now())
readAt DateTime?
externalId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Conversation Conversation @relation(fields: [conversationId], references: [id])
Contact Contact @relation(fields: [contactId], references: [id])
Portal Portal @relation(fields: [portalId], references: [id])
@@index([conversationId, sentAt])
@@index([portalId, isRead])
@@index([channel, status])
}
model Contact {
id String @id @default(cuid())
name String
email String?
phone String?
clientId String
avatarUrl String?
channel String?
lastActive DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Client Client @relation(fields: [clientId], references: [id])
Messages Message[]
@@index([clientId])
@@index([email])
@@index([phone])
}
Real-Time Updates
WebSocket Integration
The inbox uses WebSocket for real-time bidirectional communication.
Connection Setup
// InboxProvider.tsx
const { send, isConnected } = useWebSocket({
businessId: selectedPortal?.id || '',
userId: user?.id || '',
autoConnect: true,
onMessage: handleWebSocketMessage,
});
// Join inbox room
useEffect(() => {
if (isConnected) {
const room = `inbox:${selectedPortal?.id}`;
send('join-room', room);
}
}, [isConnected, selectedPortal]);
Message Types
| Event Type | Direction | Payload | Purpose |
|---|---|---|---|
inbox:message:new | Server → Client | { message: Message } | New message received |
inbox:typing:start | Both | { userId, conversationId, username } | User started typing |
inbox:typing:end | Both | { userId, conversationId } | User stopped typing |
inbox:read | Both | { userId, messageId, conversationId } | Message read receipt |
inbox:presence | Both | { userId, status, username } | User online/offline |
inbox:conversation:update | Server → Client | { conversation: Conversation } | Status/priority changed |
Real-Time Features
Typing Indicators
// Send typing indicator
const sendTypingIndicator = (isTyping: boolean) => {
send(isTyping ? 'typing-start' : 'typing-end', {
room: `inbox:${portalId}`,
data: {
userId: currentUser.id,
conversationId: selectedConversation.id,
username: currentUser.name,
},
});
};
// Receive typing indicator
if (message.type === 'typing-start') {
dispatch({
type: 'SET_TYPING_USER',
payload: {
userId: data.userId,
conversationId: data.conversationId,
username: data.username,
},
});
// Auto-clear after 5 seconds
setTimeout(() => {
dispatch({ type: 'CLEAR_TYPING_USER', payload: data.userId });
}, 5000);
}
Read Receipts
// Mark as read and send receipt
const markAsRead = async (message: Message) => {
// Update UI
dispatch({
type: 'UPDATE_MESSAGE',
payload: { messageId: message.id, updates: { isRead: true } },
});
// Broadcast via WebSocket
if (isConnected) {
send('read-receipt', {
room: `inbox:${portalId}`,
data: {
userId: currentUser.id,
messageId: message.id,
conversationId: message.conversationId,
},
});
}
// Persist to database
await service.markAsRead(message.id);
};
Presence Tracking
// Set presence on connection
useEffect(() => {
if (isConnected) {
send('presence', {
room: `inbox:${portalId}`,
data: {
status: 'online',
userId: currentUser.id,
username: currentUser.name,
},
});
}
}, [isConnected]);
// Handle presence updates
if (message.type === 'presence') {
dispatch({
type: 'SET_ACTIVE_USER',
payload: {
userId: data.userId,
isOnline: data.status === 'online',
username: data.username,
},
});
}
Reconnection Strategy
// Automatic reconnection with exponential backoff
const reconnectAttempts = useRef(0);
const maxReconnectAttempts = 5;
const baseDelay = 1000; // 1 second
const reconnect = () => {
if (reconnectAttempts.current >= maxReconnectAttempts) {
logger.error('Max reconnection attempts reached');
return;
}
const delay = baseDelay * Math.pow(2, reconnectAttempts.current);
reconnectAttempts.current++;
setTimeout(() => {
logger.info(`Reconnecting (attempt ${reconnectAttempts.current})...`);
connect();
}, delay);
};
// Reset on successful connection
useEffect(() => {
if (isConnected) {
reconnectAttempts.current = 0;
}
}, [isConnected]);
Provider Integrations
Supported Channels
| Channel | Status | Send | Receive | Features |
|---|---|---|---|---|
| Yelp | ✅ Production | ✅ | ✅ | Messages, reviews |
| ✅ Production | ✅ | ✅ | Messenger, page messages | |
| SMS | ✅ Production | ✅ | ✅ | Full Twilio/RingCentral integration |
| ⚠️ Partial | ✅ | ✅ | Via generic Message model | |
| Webchat | ✅ Production | ✅ | ✅ | Portal widget |
| Voice | ✅ Production | ✅ | ✅ | Full call handling with transcription |
| ❌ Planned | - | - | Future | |
| ❌ Planned | - | - | Future | |
| ❌ Planned | - | - | Future |
Yelp Integration
Webhook Handling
// /app/api/webhooks/yelp/route.ts
export async function POST(request: Request) {
const body = await request.json();
const { business_id, conversation_id, message } = body;
// Verify webhook signature
const signature = request.headers.get('x-yelp-signature');
if (!verifyYelpSignature(signature, body)) {
return new Response('Invalid signature', { status: 401 });
}
// Find connection
const connection = await prisma.yelpConnection.findUnique({
where: { yelpBusinessId: business_id },
});
// Create or update conversation
const conversation = await prisma.yelpConversation.upsert({
where: { yelpConversationId: conversation_id },
create: {
yelpConversationId: conversation_id,
yelpConnectionId: connection.id,
status: 'active',
},
update: {
lastMessageAt: new Date(),
},
});
// Save message
await prisma.yelpMessage.create({
data: {
yelpConversationId: conversation.id,
content: message.text,
direction: 'inbound',
senderId: message.sender_id,
senderName: message.sender_name,
sentAt: new Date(message.timestamp),
},
});
// Broadcast via WebSocket
broadcastToPortal(connection.portalId, {
type: 'inbox:message:new',
data: { message: transformMessage(message) },
});
return new Response('OK', { status: 200 });
}
Sending Messages
// POST /api/inbox/messages
await fetch('https://api.yelp.com/v3/businesses/{businessId}/conversations/{conversationId}/messages', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: messageContent,
}),
});
Facebook Integration
Webhook Handling
// /app/api/webhooks/facebook/route.ts
export async function POST(request: Request) {
const body = await request.json();
const { entry } = body;
for (const event of entry) {
for (const messaging of event.messaging) {
const { sender, recipient, message } = messaging;
// Find connection
const connection = await prisma.facebookConnection.findUnique({
where: { pageId: recipient.id },
});
// Upsert conversation
const conversation = await prisma.facebookConversation.upsert({
where: { fbConversationId: sender.id },
create: {
fbConversationId: sender.id,
facebookConnectionId: connection.id,
status: 'active',
},
update: {
lastMessageAt: new Date(),
},
});
// Save message
await prisma.facebookMessage.create({
data: {
facebookConversationId: conversation.id,
content: message.text,
direction: 'INBOUND',
senderId: sender.id,
sentAt: new Date(messaging.timestamp),
},
});
// Broadcast
broadcastToPortal(connection.portalId, {
type: 'inbox:message:new',
data: { message: transformMessage(message) },
});
}
}
return new Response('OK', { status: 200 });
}
Demo vs Production Mode
Mode Determination
// Automatic mode detection based on portal ID
const isDemo = isDemoPortal(portal.id);
// Demo portals:
// - Portal with petuniaID === 'demo-portal'
// - Portals tagged as demo in database
// - Dev/preview environments (configurable)
// Production portals:
// - All real client portals
// - Portal with actual ConnectionIntegration records
Demo Mode Behavior
| Feature | Demo Mode | Production Mode |
|---|---|---|
| Conversations | Generated mock data (20 conversations) | Real database queries |
| Messages | Simulated timestamps, names, content | Actual messages from Yelp/Facebook/SMS |
| Real-time updates | Local event simulation | WebSocket broadcasts |
| Send messages | Mock success response | API calls to providers |
| Contacts | Fake contact data | Real contact records |
| Autoresponder | UI only, no actual sends | Triggers real automated messages |
Service Factory Implementation
// components/inbox/inboxServiceFactory.ts
class InboxServiceFactory {
private mockService: InboxServiceInterface;
private realService: InboxServiceInterface;
private currentMode: 'demo' | 'real' = 'real';
switchToDemo() {
this.currentMode = 'demo';
this.notifyModeChange();
}
switchToReal() {
this.currentMode = 'real';
this.notifyModeChange();
}
getService(): InboxServiceInterface {
return this.currentMode === 'demo' ? this.mockService : this.realService;
}
getServiceForPortal(portalId: string): InboxServiceInterface {
const isDemo = isDemoPortal(portalId);
return isDemo ? this.mockService : this.realService;
}
}
export const inboxServiceFactory = new InboxServiceFactory();
Data Isolation
// CRITICAL: Production data NEVER mixes with demo data
useEffect(() => {
if (!selectedPortal) return;
const isDemo = isDemoPortal(selectedPortal.id);
const service = inboxServiceFactory.getServiceForPortal(selectedPortal.id);
// Load appropriate data
const conversations = await service.getActiveConversations();
// Conversations are tagged with source
dispatch({ type: 'SET_ACTIVE_CONVERSATIONS', payload: conversations });
}, [selectedPortal]);
API Reference
GET /api/inbox/conversations
Retrieve conversations for a portal with filtering and pagination.
Query Parameters:
petuniaID(required): Portal identifierstatus: Filter by status (active|archived|pending|completed)channel: Filter by channel (yelp|facebook|sms|email|webchat)priority: Filter by priority (low|medium|high|urgent)q: Search query (title, contact name, message content)limit: Results per page (default: 50, max: 100)offset: Pagination offset (default: 0)
Response (200):
{
"conversations": [
{
"id": "conv-abc123",
"title": "John Doe",
"participants": [
{ "id": "business", "name": "Your Business" },
{ "id": "contact-xyz", "name": "John Doe" }
],
"unreadCount": 3,
"lastMessage": {
"content": "Is your restaurant open today?",
"timestamp": "2025-12-03T10:30:00Z"
},
"createdAt": "2025-12-01T08:00:00Z",
"updatedAt": "2025-12-03T10:30:00Z",
"type": "direct",
"status": "active",
"priority": "high",
"channel": "yelp",
"messages": [...],
"source": "yelp",
"contactId": "contact-xyz",
"contactName": "John Doe",
"contactEmail": "john@example.com"
}
],
"pagination": {
"total": 127,
"limit": 50,
"offset": 0,
"hasMore": true
},
"source": "production"
}
Errors:
400: Missing or invalidpetuniaID403: Access denied (user lacks portal access)404: Portal not found500: Server error
POST /api/inbox/conversations
Create a new conversation with optional initial message.
Request Body:
{
"petuniaID": "portal-123",
"title": "New Customer Inquiry",
"channel": "webchat",
"initialMessage": "Hello, I have a question about your services.",
"contactName": "Jane Smith",
"contactEmail": "jane@example.com"
}
Response (201):
{
"conversation": {
"id": "conv-def456",
"title": "New Customer Inquiry",
"participants": [...],
"unreadCount": 0,
"lastMessage": {
"content": "Hello, I have a question about your services.",
"timestamp": "2025-12-03T11:00:00Z"
},
"createdAt": "2025-12-03T11:00:00Z",
"updatedAt": "2025-12-03T11:00:00Z",
"type": "direct",
"status": "active",
"priority": "medium",
"channel": "webchat",
"source": "generic"
},
"message": "Conversation created successfully"
}
POST /api/inbox/messages
Send a message in a conversation.
Request Body:
{
"conversationId": "conv-abc123",
"content": "Thank you for your inquiry! We're open 9 AM - 9 PM today.",
"channel": "yelp",
"isAutomated": false
}
Response (200):
{
"message": {
"id": "msg-xyz789",
"conversationId": "conv-abc123",
"content": "Thank you for your inquiry! We're open 9 AM - 9 PM today.",
"timestamp": "2025-12-03T11:05:00Z",
"from": "Business",
"senderId": "business",
"senderName": "Your Business",
"isRead": false,
"channel": "yelp",
"isAutomated": false
}
}
GET /api/inbox/autoresponder
Get autoresponder settings for a portal.
Query Parameters:
petuniaID(required): Portal identifier
Response (200):
{
"enabled": true,
"delay": 5,
"message": "Thank you for your message. We will respond during business hours (9 AM - 5 PM).",
"applyTo": ["yelp", "facebook", "sms", "email"],
"businessHoursOnly": true,
"activeHours": {
"start": "09:00",
"end": "17:00"
},
"activeDays": ["monday", "tuesday", "wednesday", "thursday", "friday"],
"includeGreeting": true,
"customSignature": "- The Team at ${businessName}"
}
POST /api/inbox/suggestions
Generate AI-powered response suggestions.
Request Body:
{
"conversationId": "conv-abc123",
"messageId": "msg-xyz789"
}
Response (200):
{
"suggestions": [
{
"id": "sg-1",
"text": "Thank you for reaching out! We're open from 9 AM to 9 PM today. Feel free to stop by!",
"confidence": 0.92
},
{
"id": "sg-2",
"text": "Hi! Yes, we're open today until 9 PM. Looking forward to seeing you!",
"confidence": 0.87
},
{
"id": "sg-3",
"text": "Hello! Our hours today are 9 AM - 9 PM. Let us know if you have any other questions!",
"confidence": 0.85
}
]
}
Configuration
Environment Variables
Required
| Variable | Description | Example |
|---|---|---|
DATABASE_URL | PostgreSQL connection string | postgresql://user:pass@host:5432/db |
NEXT_PUBLIC_SUPABASE_URL | Supabase project URL | https://xxx.supabase.co |
NEXT_PUBLIC_SUPABASE_ANON_KEY | Supabase anon key | eyJ... |
ANTHROPIC_API_KEY | Claude API key for AI suggestions | sk-ant-... |
Optional
| Variable | Description | Default |
|---|---|---|
NEXT_PUBLIC_WS_URL | WebSocket server URL | Auto-detected |
INBOX_POLLING_INTERVAL | Polling fallback interval (ms) | 30000 (30s) |
INBOX_MESSAGE_PAGE_SIZE | Messages per page | 50 |
AI_SUGGESTION_TIMEOUT | AI suggestion timeout (ms) | 10000 (10s) |
Autoresponder Configuration
// Default autoresponder settings
const defaultSettings: AutoresponderSettings = {
enabled: false,
delay: 5, // minutes
message: 'Thank you for your message. We will respond as soon as possible.',
applyTo: ['yelp', 'sms', 'email', 'webchat'],
businessHoursOnly: true,
activeHours: {
start: '09:00',
end: '17:00',
},
activeDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'],
includeGreeting: true,
customSignature: '',
};
Canned Responses
// Pre-populated canned responses
const defaultCannedResponses: CannedResponse[] = [
{
id: 'cr-1',
title: 'Business Hours',
content: 'We are open Monday-Friday 9 AM - 5 PM. Feel free to stop by!',
},
{
id: 'cr-2',
title: 'Thank You',
content: 'Thank you for choosing ${businessName}! We appreciate your business.',
},
{
id: 'cr-3',
title: 'Follow Up',
content: 'Just checking in to see if you need anything else. Let us know!',
},
];
Testing Strategy
Unit Tests
| Test File | Tests | Coverage |
|---|---|---|
/tests/components/inbox/InboxProvider.test.tsx | State management, reducer logic | 85% |
/tests/lib/services/conversation-service.test.ts | CRUD operations | 92% |
/tests/lib/hooks/useWebSocket.test.ts | Connection, reconnection | 78% |
Integration Tests
| Test File | Tests | Coverage |
|---|---|---|
/tests/integration/inbox-api.test.ts | API routes end-to-end | Full flow |
/tests/integration/inbox-websocket.test.ts | Real-time messaging | WebSocket lifecycle |
E2E Tests (Cypress)
# Run inbox E2E tests
pnpm cypress run --spec "cypress/e2e/inbox/**/*.cy.ts"
Test Scenarios:
- View conversation list
- Filter by channel/status
- Search conversations
- Open conversation detail
- Send message
- Receive real-time message
- Enable autoresponder
- Use canned response
- Generate AI suggestion
- Mark conversation as read
Performance
Metrics
| Metric | Target | Actual | Status |
|---|---|---|---|
| Initial load time | <2s | ~1.2s | ✅ |
| Message send latency | <500ms | ~200ms | ✅ |
| WebSocket connection | <1s | ~300ms | ✅ |
| Search response time | <300ms | ~150ms | ✅ |
| AI suggestion generation | <5s | ~3s | ✅ |
| Pagination (50 items) | <200ms | ~80ms | ✅ |
Optimizations
Virtual Scrolling
// MessageList uses react-window for efficient rendering
import { FixedSizeList } from 'react-window';
<FixedSizeList
height={600}
itemCount={messages.length}
itemSize={80}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<MessageItem message={messages[index]} />
</div>
)}
</FixedSizeList>
Lazy Loading
// Load conversations incrementally
const [hasMore, setHasMore] = useState(true);
const [offset, setOffset] = useState(0);
const loadMore = async () => {
const response = await api.get(`/api/inbox/conversations`, {
params: { petuniaID, limit: 50, offset },
});
setConversations(prev => [...prev, ...response.data.conversations]);
setHasMore(response.data.pagination.hasMore);
setOffset(offset + 50);
};
Memoization
// Expensive computations are memoized
const filteredConversations = useMemo(() => {
return applyFilters(conversations, {
searchTerm,
selectedChannel,
showReadMessages,
starredFilter,
});
}, [conversations, searchTerm, selectedChannel, showReadMessages, starredFilter]);
Troubleshooting
Common Issues
"WebSocket connection failed"
- Check
NEXT_PUBLIC_WS_URLenvironment variable - Verify WebSocket server is running
- Check for proxy/firewall blocking WebSocket connections
- Fallback: System will automatically use REST polling
"Messages not appearing in real-time"
- Verify WebSocket connection status (check
isConnected) - Check browser console for WebSocket errors
- Ensure portal ID matches between client and server
- Verify webhook configuration for Yelp/Facebook
"Conversations not loading"
- Check portal ID in URL or context
- Verify user has
UserPortalAccessfor the portal - Check database connection
- Review API response in Network tab
"Autoresponder not sending"
- Verify
enabled: truein settings - Check if current time is within
activeHoursandactiveDays - Ensure
businessHoursOnlyis configured correctly - Review
delaysetting (messages send after delay expires)
"AI suggestions not generating"
- Verify
ANTHROPIC_API_KEYis set - Check Claude API rate limits
- Review timeout setting (default 10s)
- Fallback: System provides generic suggestions
"Cross-portal data leakage"
- CRITICAL: Report immediately to security team
- Check
portalIdin all queries - Verify
UserPortalAccesschecks are enforced - Review audit logs for unauthorized access
Debug Commands
# Check WebSocket connection
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" \
-H "Sec-WebSocket-Version: 13" \
http://localhost:3000/api/websocket
# Test conversation API
curl -X GET "http://localhost:3000/api/inbox/conversations?petuniaID=portal-123"
# Verify autoresponder settings
curl -X GET "http://localhost:3000/api/inbox/autoresponder?petuniaID=portal-123"
Logging
// Enable debug logging for inbox
localStorage.setItem('debug', 'inbox:*');
// Logs appear in console:
// inbox:provider Initializing with portal: portal-123
// inbox:websocket Connected to ws://localhost:3000
// inbox:service Fetched 15 conversations
File Reference
Core Files
| File | Purpose | Lines |
|---|---|---|
/components/inbox/InboxProvider.tsx | Context provider, state management | ~2,100 |
/components/inbox/InboxPage.tsx | Main inbox container | ~400 |
/components/inbox/ConversationList.tsx | Conversation list UI | ~300 |
/components/inbox/MessageDetail.tsx | Message thread UI (messages embedded) | ~500 |
/components/inbox/ContactInfoSidebar.tsx | Contact details panel | ~200 |
/components/inbox/types.ts | TypeScript interfaces | ~500 |
/components/inbox/inboxServiceInterface.ts | Service contract | ~80 |
/components/inbox/inboxServiceFactory.ts | Service factory (demo/real) | ~150 |
/components/inbox/realService.ts | Production API service | ~300 |
API Routes
| Route | Methods | Purpose |
|---|---|---|
/app/api/inbox/conversations/route.ts | GET, POST, PUT, DELETE | Conversation CRUD |
/app/api/inbox/messages/route.ts | GET, POST | Message handling |
/app/api/inbox/autoresponder/route.ts | GET, POST | Autoresponder settings |
/app/api/inbox/search/route.ts | GET | Full-text search |
/app/api/inbox/suggestions/route.ts | POST | AI suggestions |
/app/api/inbox/dashboard/route.ts | GET | Inbox metrics |
Services
| File | Purpose |
|---|---|
/lib/services/conversation-service.ts | Conversation CRUD operations |
/lib/services/ai/mock-ai-service.ts | AI response generation (Claude) |
/lib/hooks/useWebSocket.ts | WebSocket connection management |
/lib/hooks/useInbox.ts | Re-export of InboxProvider hook |
Version History
| Version | Date | Changes |
|---|---|---|
| 2.3.1 | 2025-12-05 | Added verification snapshot, CI/test links, and owner mapping |
| 2.3.0 | 2025-12-04 | Real-time hardening: Message deduplication in reducer (handles Redis retries, reconnects), INCREMENT_CONVERSATION_UNREAD action (fixes stale closure), WebSocket handler refactored for stable deps, removed deprecated MessageList component, added hardRefreshConversation() and clearConversationMessagesCache() methods for manual cache control, fixed WebSocket join/leave effect dependency |
| 2.2.0 | 2025-12-03 | UUID generation fix, Contact FK validation, message archiving |
| 2.1.0 | 2025-11-27 | Strict validation, removed silent demo fallback, improved errors |
| 2.0.0 | 2025-11-27 | Complete rewrite with real Prisma queries, demo isolation |
| 1.5.0 | 2025-11-15 | AI suggestions, typing indicators, presence tracking |
| 1.0.0 | 2025-10-01 | Initial unified inbox with Yelp + Facebook |
Production Status: ✅ Stable
Known Issues:
- External provider webhooks (FB/IG/Yelp) stubbed in CI; staging-only validation
- Search relevance and inbox SLA alerts not performance-tested yet
Next Milestones:
- Instagram Direct integration (Q1 2026)
- Team collaboration features (Q2 2026)
- Rich media attachments (Q2 2026)
Generated: December 5, 2025