Add new conversation feature to SMS module

Enable starting ad-hoc SMS conversations from the conversations page
by searching contacts across SMS lists, CRM, and existing threads,
then composing and sending a first message.

Bunker Admin
This commit is contained in:
bunker-admin 2026-02-28 16:55:24 -07:00
parent d835f0837b
commit aaba7df97d
4 changed files with 505 additions and 7 deletions

View File

@ -0,0 +1,231 @@
import { useState, useEffect } from 'react';
import { Modal, Input, List, Tag, Typography, Space, Button, App } from 'antd';
import { SendOutlined, PhoneOutlined, UserOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { useDebounce } from '@/hooks/useDebounce';
import type { SmsContactSearchResult, SmsConversation } from '@/types/sms';
const { Text } = Typography;
const { TextArea } = Input;
const SOURCE_LABELS: Record<string, { label: string; color: string }> = {
sms_contact: { label: 'SMS List', color: 'purple' },
crm_contact: { label: 'CRM', color: 'blue' },
conversation: { label: 'Previous', color: 'default' },
};
interface Props {
open: boolean;
onClose: () => void;
onCreated: (conv: SmsConversation) => void;
}
export default function NewConversationModal({ open, onClose, onCreated }: Props) {
const { message } = App.useApp();
const [query, setQuery] = useState('');
const [results, setResults] = useState<SmsContactSearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [selectedContact, setSelectedContact] = useState<SmsContactSearchResult | null>(null);
const [msgText, setMsgText] = useState('');
const [sending, setSending] = useState(false);
const debouncedQuery = useDebounce(query, 300);
// Search contacts when debounced query changes
useEffect(() => {
if (!debouncedQuery || debouncedQuery.length < 2) {
setResults([]);
return;
}
let cancelled = false;
setSearching(true);
api.get<{ results: SmsContactSearchResult[] }>('/sms/conversations/contact-search', {
params: { q: debouncedQuery },
}).then(({ data }) => {
if (!cancelled) setResults(data.results);
}).catch(() => {
if (!cancelled) setResults([]);
}).finally(() => {
if (!cancelled) setSearching(false);
});
return () => { cancelled = true; };
}, [debouncedQuery]);
// Check if query looks like a phone number (mostly digits, 7+)
const digitsOnly = query.replace(/\D/g, '');
const isPhoneQuery = digitsOnly.length >= 7;
const phoneAlreadyInResults = isPhoneQuery && results.some((r) => r.phone.includes(digitsOnly));
const handleSelect = (contact: SmsContactSearchResult) => {
setSelectedContact(contact);
};
const handleManualPhone = () => {
setSelectedContact({
phone: digitsOnly,
name: null,
source: 'sms_contact',
sourceId: '',
});
};
const handleSend = async () => {
if (!selectedContact || !msgText.trim()) return;
setSending(true);
try {
const { data } = await api.post<SmsConversation>('/sms/conversations', {
phone: selectedContact.phone,
message: msgText.trim(),
contactName: selectedContact.name || undefined,
contactId: selectedContact.contactId || undefined,
});
message.success('Message queued');
onCreated(data);
handleReset();
} catch (err: any) {
const errMsg = err.response?.data?.error || 'Failed to send message';
message.error(errMsg);
} finally {
setSending(false);
}
};
const handleReset = () => {
setQuery('');
setResults([]);
setSelectedContact(null);
setMsgText('');
onClose();
};
const handleBack = () => {
setSelectedContact(null);
setMsgText('');
};
return (
<Modal
title="New Conversation"
open={open}
onCancel={handleReset}
footer={null}
destroyOnHidden
width={480}
>
{!selectedContact ? (
// Step 1: Contact Search
<div>
<Input
placeholder="Search by name or phone number..."
prefix={<PhoneOutlined />}
value={query}
onChange={(e) => setQuery(e.target.value)}
allowClear
autoFocus
/>
<div style={{ marginTop: 12, maxHeight: 320, overflowY: 'auto' }}>
{/* Manual phone entry option */}
{isPhoneQuery && !phoneAlreadyInResults && (
<div
onClick={handleManualPhone}
style={{
padding: '8px 12px',
cursor: 'pointer',
borderRadius: 6,
marginBottom: 4,
border: '1px dashed rgba(255,255,255,0.15)',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.06)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Space>
<PhoneOutlined />
<Text>Use number: {digitsOnly}</Text>
<Tag color="green">Manual</Tag>
</Space>
</div>
)}
<List
loading={searching}
dataSource={results}
locale={{ emptyText: query.length >= 2 ? 'No contacts found' : 'Type to search...' }}
renderItem={(item) => (
<List.Item
onClick={() => handleSelect(item)}
style={{ cursor: 'pointer', padding: '8px 12px', borderRadius: 6 }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.06)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%', alignItems: 'center' }}>
<Space>
<UserOutlined />
<div>
<Text strong>{item.name || item.phone}</Text>
{item.name && <Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>{item.phone}</Text>}
</div>
</Space>
<Tag color={SOURCE_LABELS[item.source]?.color}>
{SOURCE_LABELS[item.source]?.label}
</Tag>
</div>
</List.Item>
)}
/>
</div>
</div>
) : (
// Step 2: Compose Message
<div>
<Button type="link" onClick={handleBack} style={{ padding: 0, marginBottom: 8 }}>
&larr; Change contact
</Button>
<div style={{
padding: '10px 14px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 8,
marginBottom: 12,
}}>
<Space>
<UserOutlined />
<div>
{selectedContact.name && <Text strong>{selectedContact.name}</Text>}
<Text type="secondary" style={{ marginLeft: selectedContact.name ? 8 : 0 }}>
{selectedContact.phone}
</Text>
</div>
</Space>
</div>
<TextArea
rows={4}
value={msgText}
onChange={(e) => setMsgText(e.target.value)}
placeholder="Type your message..."
maxLength={1600}
showCount
autoFocus
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
<Space>
<Button onClick={handleReset}>Cancel</Button>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
loading={sending}
disabled={!msgText.trim()}
>
Send
</Button>
</Space>
</div>
</div>
)}
</Modal>
);
}

View File

@ -1,10 +1,11 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Row, Col, List, Card, Input, Button, Tag, Badge, Space, Typography, Empty, Spin, App, Checkbox, Select, Collapse } from 'antd';
import { SendOutlined, CheckOutlined, ReadOutlined, CloseCircleOutlined, LinkOutlined } from '@ant-design/icons';
import { SendOutlined, CheckOutlined, ReadOutlined, CloseCircleOutlined, LinkOutlined, PlusOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { SmsConversation, SmsPaginatedResponse } from '@/types/sms';
import type { AppOutletContext } from '@/types/api';
import { useOutletContext, Link } from 'react-router-dom';
import NewConversationModal from './NewConversationModal';
const { Text, Paragraph } = Typography;
const { Search, TextArea } = Input;
@ -37,6 +38,9 @@ export default function SmsConversationsPage() {
const [notesSaving, setNotesSaving] = useState(false);
const [tagsSaving, setTagsSaving] = useState(false);
// New conversation modal
const [newConvOpen, setNewConvOpen] = useState(false);
// Bulk selection state
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkLoading, setBulkLoading] = useState(false);
@ -179,12 +183,17 @@ export default function SmsConversationsPage() {
<Row gutter={16} style={{ height: 'calc(100vh - 200px)', minHeight: 400 }}>
{/* Conversation List (left panel) */}
<Col xs={24} md={8} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Search
placeholder="Search by phone or name..."
onSearch={(v) => { setSearch(v); fetchConversations(v); }}
allowClear
style={{ marginBottom: 8 }}
style={{ flex: 1 }}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setNewConvOpen(true)}>
New
</Button>
</div>
{/* Bulk action bar */}
{selectedIds.size > 0 && (
@ -396,6 +405,15 @@ export default function SmsConversationsPage() {
</Card>
)}
</Col>
<NewConversationModal
open={newConvOpen}
onClose={() => setNewConvOpen(false)}
onCreated={(conv) => {
fetchConversations();
if (conv) setSelected(conv);
}}
/>
</Row>
);
}

View File

@ -24,6 +24,60 @@ router.get('/', async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /api/sms/conversations/contact-search — search contacts for new conversation
router.get('/contact-search', async (req, res, next) => {
try {
const q = (req.query.q as string || '').trim();
if (q.length < 2) {
res.json({ results: [] });
return;
}
const results = await smsConversationsService.searchContacts(q);
res.json({ results });
} catch (err) { next(err); }
});
// POST /api/sms/conversations — start new conversation
router.post('/', async (req, res, next) => {
try {
const { phone, message, contactName, contactId } = req.body as {
phone?: string;
message?: string;
contactName?: string;
contactId?: string;
};
if (!phone || typeof phone !== 'string' || phone.replace(/\D/g, '').length < 7) {
res.status(400).json({ error: 'Valid phone number is required (min 7 digits)' });
return;
}
if (!message || typeof message !== 'string' || message.trim().length === 0) {
res.status(400).json({ error: 'Message is required' });
return;
}
if (message.length > 1600) {
res.status(400).json({ error: 'Message cannot exceed 1600 characters' });
return;
}
const conversation = await smsConversationsService.startConversation({
phone,
message: message.trim(),
contactName,
contactId,
});
res.status(201).json(conversation);
} catch (err: any) {
if (err.statusCode === 409) {
res.status(409).json({ error: err.message });
return;
}
if (err.statusCode === 400) {
res.status(400).json({ error: err.message });
return;
}
next(err);
}
});
// GET /api/sms/conversations/stats — conversation stats
router.get('/stats', async (_req, res, next) => {
try {

View File

@ -2,6 +2,24 @@ import { prisma } from '../../../config/database';
import { Prisma } from '@prisma/client';
import { smsQueueService } from '../../../services/sms-queue.service';
/**
* Normalize a phone number: strip non-digit characters, validate 10-11 digits.
*/
function normalizePhone(raw: string): string | null {
const digits = raw.replace(/\D/g, '');
if (digits.length === 10) return digits;
if (digits.length === 11 && digits.startsWith('1')) return digits;
return null;
}
export interface ContactSearchResult {
phone: string;
name: string | null;
source: 'sms_contact' | 'crm_contact' | 'conversation';
sourceId: string;
contactId?: string;
}
export const smsConversationsService = {
async findAll(options: {
page?: number;
@ -171,4 +189,181 @@ export const smsConversationsService = {
return { updated: result.count };
},
/**
* Search contacts across SMS lists, CRM contacts, and existing conversations.
* Deduplicates by phone number, prioritizing SMS contacts > CRM > conversations.
*/
async searchContacts(query: string, limit = 20): Promise<ContactSearchResult[]> {
const seen = new Map<string, ContactSearchResult>();
const [smsEntries, crmContacts, existingConvs] = await Promise.all([
// 1. SMS Contact List Entries
prisma.smsContactListEntry.findMany({
where: {
OR: [
{ phone: { contains: query } },
{ name: { contains: query, mode: 'insensitive' } },
],
},
take: limit,
select: { id: true, phone: true, name: true },
}),
// 2. CRM Contacts (with phones) — exclude opted out
prisma.contact.findMany({
where: {
doNotContact: false,
smsOptOut: false,
OR: [
{ displayName: { contains: query, mode: 'insensitive' } },
{ phone: { contains: query } },
{ phones: { some: { phone: { contains: query } } } },
],
},
take: limit,
select: {
id: true,
displayName: true,
phone: true,
phones: { select: { phone: true }, take: 5 },
},
}),
// 3. Existing conversations
prisma.smsConversation.findMany({
where: {
OR: [
{ phone: { contains: query } },
{ contactName: { contains: query, mode: 'insensitive' } },
],
},
take: limit,
select: { id: true, phone: true, contactName: true, contactId: true, status: true },
}),
]);
// Add SMS contacts first (highest priority)
for (const entry of smsEntries) {
if (!seen.has(entry.phone)) {
seen.set(entry.phone, {
phone: entry.phone,
name: entry.name,
source: 'sms_contact',
sourceId: entry.id,
});
}
}
// Add CRM contacts
for (const contact of crmContacts) {
const phones: string[] = [];
if (contact.phone) phones.push(contact.phone);
for (const cp of contact.phones) {
if (!phones.includes(cp.phone)) phones.push(cp.phone);
}
for (const phone of phones) {
if (!seen.has(phone)) {
seen.set(phone, {
phone,
name: contact.displayName,
source: 'crm_contact',
sourceId: contact.id,
contactId: contact.id,
});
}
}
}
// Add existing conversations (lowest priority)
for (const conv of existingConvs) {
if (!seen.has(conv.phone)) {
seen.set(conv.phone, {
phone: conv.phone,
name: conv.contactName,
source: 'conversation',
sourceId: conv.id,
contactId: conv.contactId || undefined,
});
}
}
return Array.from(seen.values()).slice(0, limit);
},
/**
* Start a new ad-hoc conversation or reuse an existing one, then send the first message.
*/
async startConversation(input: {
phone: string;
message: string;
contactName?: string;
contactId?: string;
}) {
const normalized = normalizePhone(input.phone);
if (!normalized) throw Object.assign(new Error('Invalid phone number'), { statusCode: 400 });
// Use a transaction to prevent race conditions on conversation lookup/create
const conversation = await prisma.$transaction(async (tx) => {
// Look for existing ad-hoc conversation (campaignId = null)
const existing = await tx.smsConversation.findFirst({
where: { phone: normalized, campaignId: null },
});
if (existing) {
if (existing.status === 'OPTED_OUT') {
throw Object.assign(new Error('Cannot message opted-out contact'), { statusCode: 409 });
}
// Reopen if closed, update stats
return tx.smsConversation.update({
where: { id: existing.id },
data: {
status: 'ACTIVE',
totalMessages: { increment: 1 },
lastMessageAt: new Date(),
// Update contact info if provided and not already set
contactName: existing.contactName || input.contactName || undefined,
contactId: existing.contactId || input.contactId || undefined,
},
});
}
// Create new conversation
return tx.smsConversation.create({
data: {
phone: normalized,
contactName: input.contactName || null,
contactId: input.contactId || null,
status: 'ACTIVE',
totalMessages: 1,
lastMessageAt: new Date(),
},
});
});
// Create outbound message
const smsMessage = await prisma.smsMessage.create({
data: {
phone: normalized,
message: input.message,
direction: 'OUTBOUND',
status: 'PENDING',
connectionType: 'termux',
conversationId: conversation.id,
},
});
// Queue the SMS send
await smsQueueService.addSmsJob({
recipientId: smsMessage.id,
campaignId: '',
phone: normalized,
message: input.message,
attemptNumber: 1,
});
// Return full conversation with messages
return this.findById(conversation.id);
},
};